The Widlarz Group Blog

Reacreating Quibi's cards swipe animation with React Native and Reanimated

February 17, 2021

react native

reanimated

mobile

animations

Introduction

Some time ago, a new video streaming app arose. Among services like Netflix, HBO GO or Amazon Prime, we also got a chance to consume tv series and movies on another platform called Quibi. The idea of it is slightly different though - app allows us to watch mainly original productions that can be viewed in either horizontal position or PORTRAIT! Depending on our choice, the app would serve us differently cut content.

The idea was suppossed to be revolutionary, but in the end, it did not make a huge success. Nevertheless, the mobile app itself has a nice card swiping animation that we will try to recreate with React Native and Reanimated in this article!

Below, a little preview of the original app and the one that we will build in this article:

Original app

Final recreated animation

As you can spot, the cards in the stack, apart from moving up and down, are also scaling down and snap to the center of the screen. It is really an interesting alternative to just a simple <FlatList /> with some items. Such features really enrichen the user experience.

We will focus mainly on an animation itself, not on the styling. That is why I created a starter project with some mock movie data and basic styled components. You can work along using this starter repo on GitHub.

Before we start - how our animation should work

Shall we start? 😎

So before we write a first line of code, let’s quickly plan our work and put down all the things we need to do in points below:

  1. At the very beginning, we need to handle the gesture events like swiping and touching
  2. Then, we can start working on animating the cards up and down. Every swipe would both move the stack and simultaneously scale down the previous card.
  3. Each finished or cancelled touch gesture would then trigger either the current card change or snap the whole stack to the center of the screen in such a way, that the active card is fully visible and centered. For this to work nicely, we will declare a few constraints to help us trigger such actions - velocity of the swipe, state of the swipe and the swiped distance.

Managing swipe gestures

In order to handle the gesture events, we will of course use react-native-gesture-handler for handling it.

Let’s wrap a <FlatList /> with <PanGestureHandler /> in our HomeScreen and let’s also assign the gesture events data to the Animated.Values with Animated.event helper, just like so:

/src/screens/HomeScreen.tsx

import {
  FlatList,
  PanGestureHandler,
  State,
} from "react-native-gesture-handler"

...

const HomeScreen = () => {

...

  const translationY = useRef<Animated.Value<number>>(new Value(0)).current;
  const state = useRef<Animated.Value<State>>(new Value(State.UNDETERMINED))
    .current;
  const velocityY = useRef<Animated.Value<number>>(new Value(0)).current;

  const onGesture = event([
    {
      nativeEvent: {
        translationY,
        state,
        velocityY,
      },
    },
  ]);

...

Let’s also pass the onGesture to <PanGestureHandler /> with which we wrap our list:

/src/screens/HomeScreen.tsx

...

  return (
    <View style={styles.container}>
      <Header />
      <PanGestureHandler
        onGestureEvent={onGesture}
        onHandlerStateChange={onGesture}>
        <Animated.View
          style={styles.contentContainer}
          onLayout={({nativeEvent: {layout}}) => setCardHeight(layout.height)}>
          <FlatList
            contentContainerStyle={styles.contentContainer}
            removeClippedSubviews={false}
            data={cardsList}
            scrollEnabled={false}
            showsVerticalScrollIndicator={false}
            keyExtractor={(item) => item.id}
            renderItem={({item: card}) => {
              return (
                <Animated.View
                  style={StyleSheet.flatten([
                    styles.cardWrapper,
                    {
                      height: cardHeight,
                    },
                  ])}
                  key={card.id}>
                  <Card {...card} />
                </Animated.View>
              );
            }}
          />
        </Animated.View>
      </PanGestureHandler>
    </View>
  );
}

We additionaly put our <FlatList /> inside <Animated.View /> and moved the onLayout to aforementioned <Animated View />. A reason for this is that PanGestureHandler requires all of its child to be of Animated kind.

With this being done, we now have the access to the translation value on Y axis for our swipe list, its velocity and its state, telling us whether the swipe has started, is ongoing, finished or maybe cancelled.

Done! First part of coding is behind us and now we can go on and focus on the animation logic.

Writing the animation logic

As you might probably know, in order to animate anything with react-native-reanimated (at least with its API v1), we have to create a function that handles all the logic and returns an animated node. The logic must be coded with a special set of helper functions, e.g. instead of a classic if-else block, we have to use the cond helper function.

Feel free to study the official documentation to browse through all of them here

Now, let’s make a new file in which we will declare our animation. We can put it in a separate folder in our /src directory. Inside, create the function which should take in all the data that we previously tried to assign to animated values (translation data, velocity and state). It should also consume information about the card height which is different for every screen obviously 👨🏽‍🏫 and contain the state and config configuration objects (explained below).

Code:

/src/animations/cardSwipe.ts

import Animated, {block} from 'react-native-reanimated'
import {State} from 'react-native-gesture-handler'

const {Value} = Animated

export const cardSwipe = (
  translationY: Animated.Value<number>,
  dragState: Animated.Value<State>,
  velocityY: Animated.Value<number>,
  cardHeight: number,
) => {
  const state = {
    finished: new Value(0),
    velocity: new Value(0),
    position: new Value(0),
    time: new Value(0),
  }

  const config = {
    damping: 12,
    tension: 1,
    friction: 2,
    mass: 0.2,
    stiffness: 121.6,
    overshootClamping: false,
    restSpeedThreshold: 0.001,
    restDisplacementThreshold: 0.001,
    toValue: new Value(0),
  }

  return block([translationY])
}

As mentioned before, inside the body of this function we declared two objects - state and config. The former represents the current state of the animation (its progress, whether it has finished etc) and the latter is simply (as the name suggests) a config object that defines how the animation is going to behave. In this case, it is the config for a spring type of animation.

And last but not least is the return block. It returns the translationY value for now, so it actually isn’t doing anything. At least yet! 😂 With a little bit of patience, we will create a really nice animation in a matter of minutes! 🏃🏽‍♀️

But before we go any further, let’s think for a while. Right now, we don’t know what is the current active card, so we would need its index number as well. We also don’t know to what moment should we allow our swipe to be active - we want to lock the swiping at some point, for instance when we reach the end of the list. For this to happen, we have to add two more parameters - index and maxIndex which would be of a number type.

Apart from it, we also need a Clock for our animation to work. It is how the animations in reanimated work (Reanimated docs: Clock).

/src/animations/cardSwipe.ts

...

export const cardSwipe = (
  translationY: Animated.Value<number>,
  dragState: Animated.Value<State>,
  velocityY: Animated.Value<number>,
  activeIndex: Animated.Value<number>,
  maxIndex: number,
  clock: Animated.Clock,
  cardHeight: number,
) => {

...

Let’s go ahead now and import this function in our <HomeScreen /> component, invoke and pass it to a variable. We also need to create another set of Animated.Values for an aforementioned index value and an Animated.Clock:

/src/screens/HomeScreen.tsx

import {cardSwipe} from '../animations/cardSwipe';

...

  const [cardHeight, setCardHeight] = useState(0);
  const translationY = useRef<Animated.Value<number>>(new Value(0)).current;
  const state = useRef<Animated.Value<State>>(new Value(State.UNDETERMINED))
    .current;
  const velocityY = useRef<Animated.Value<number>>(new Value(0)).current;
  const clock = useRef<Animated.Clock>(new Clock()).current;
  const activeCardIndex = useRef<Animated.Value<number>>(new Value(0)).current;

  const cardAnimation = useMemo(
    () =>
      cardSwipe(
        translationY,
        state,
        velocityY,
        activeCardIndex,
        cardsList.length - 1,
        clock,
        cardHeight,
      ),
    [cardsList, cardHeight],
  );

...

You may ask why do we use useRef hook to set these values and not just simply assigning them to a variable. It is because each component’s re-render would re-assign them, thus what follows - reset our whole animation. We don’t want such thing to happen. A good practise with these values and a clock is to store them with e.g. useRef or even with useState (const [value] = useState(new Animated.Value(0))). We additionally wrap the cardSwipe invoke with useMemo, as we don’t want to redeclare it on every rerender as well (only when a certain value change). Don’t worry, the e.g. translationY or veloctyY won’t re-memoize the useMemo as these values are just instances of the animated node, they are decalared once, on component mount.

Having done this, we can now go back to our animating function. Let’s first tackle the easiest part, which would be the swipe handling:

/src/animations/cardSwipe.ts

...

  return block([
    cond(
      or(eq(dragState, State.ACTIVE), eq(dragState, State.BEGAN)),
      [stopClock(clock), add(translationY, state.position)],
      [translationY],
    ),
  ]);

...

Our condition statement checks whether the touch event is ongoing. We do it with or and eq utils. If this node evaluates to some truthy value, the second node will be evaluated and return a given value. Otherwise, the third node will be triggered. You can take a peek at the docs - Reanimated: cond - to get some more thorough knowledge on it 💥.

As for the second node itself, it simply stops the clock being a representation of our animation and then adds the current translationY value to the animation state (state.position). When add is evaluated, it returns the summed value (think of it as some kind of context).

The third node on the other hand, returns nothing but the translationY value. It is not the final solution as we want to add some logic here which is supposed to run some checks and configure the animation - whether we should animate back to the initial card position or maybe go up or down in the stack of the cards. But for now, let’s not focus on it and first go back to our <HomeScreen /> component and connect our simple animation to the actuall View. With the setup that we currently have, it should only move upwards or downwards.

Let’s look at our <FlatList /> and put some code in there:

/src/screens/HomeScreen.tsx

    <FlatList
      contentContainerStyle={styles.contentContainer}
      ...
      renderItem={({item: card}) => {
        return (
          <Animated.View
            style={StyleSheet.flatten([
              styles.cardWrapper,
              {
                height: cardHeight,
              },
              {
                transform: [{translateY: cardAnimation}],
              },
            ])}
            key={card.id}>
            <Card {...card} />
          </Animated.View>
        );
      }}
    />

So what we did was just adding the transform style property to the <Animated.View />.

If we were to run the app now and try to swipe up and down, this is what we would see inside our emulator or physical phone:

Translating on Y axis

Now, it would make sense to tackle the interpolation of other style -> scale. As in the original app, we want our current card in the stack to stay in place and scale down when swiping up or move down when swiping downwards (a default-ish behavior in fact). As for the first card in the stack, it should do nothing for now (when swiping upwards, that is 🤪).

Let’s first update the StyleSheet for our cards and change their position to absolute and update the tranfrom property one more time:

/src/screens/HomeScreen.tsx

const styles = StyleSheet.create({
...

  cardWrapper: {
    padding: PADDING,
    width: '100%',
    position: 'absolute', // <- added
    top: 0, // <- added
    left: 0, // <- added
  },
});

...

Let’s also update our swiping behaviour for each card that we render. For this, let’s also go back to the <FlatList /> again:

/src/screens/HomeScreen.tsx

  <FlatList
    ...

    renderItem={({item: card, index}) => {
      const position = index * -cardHeight;
      const nextPosition = (index + 1) * -cardHeight;
      const prevPosition = (index - 1) * -cardHeight;
      const firstItemSwipeDownTranslationBlock =
        index === 0 ? 50 : prevPosition;

      const translateY = interpolate(cardAnimation, {
        inputRange: [nextPosition, position, prevPosition],
        outputRange: [
          position,
          position,
          firstItemSwipeDownTranslationBlock,
        ],
      });

      const scale = interpolate(cardAnimation, {
        inputRange: [nextPosition, position, prevPosition],
        outputRange: [0.8, 1, 1],
        extrapolate: Extrapolate.CLAMP,
      });

      return (
        <Animated.View
          style={StyleSheet.flatten([
            styles.cardWrapper,
            {
              height: cardHeight,
            },
            {
              transform: [
                {translateY: add(-position, translateY)},
                {scale},
              ],
            },
          ])}
          key={card.id}>
          <Card {...card} />
        </Animated.View>
      );
    }}
  />

  ...

We put quite a bit of code above. Let’s get over each line and try to explain what is actually happening in there.

What has changed? We added the scale value which is the outcome of the interpolation. We also changed the translateY transform style property to a new value - a sum of the position variable and translateY variable.

With our card wrapper styling changed to absolute position, each card in the stack is in the same position, each put on top of the other.

By simply adding the position (which is the cardHeight multiplied by the index of the card) to the new translateY value, the cards are again located one below the other, not stacked on top of each other.

As cards position is already fixed, we can now block each previous card from moving upwards and instead staying in place and scaling down (with a bit of help from interpolation).

To help you understand, here’s a simple graphic:

Card Interpolation

Let’s assume that the current card in the stack is the fourth one (index of 3). Let’s also assume that the height of each card is 100px.

In such case, the current position of the card would be 300px (from the top in Y axis). Going further by swiping down, the card would start moving towards 400px and towards the 200px in the opposite direcion. The latter we want to block though. That is why we declare position, prevPosition and nextPosition for each rendered card. This way we can use this data to interpolate the animated value.

For every incoming position, we output the same value. Going further, we output the same value for every incoming one that is closer to the nextPosition. But for every value that is going towards prevPostion, starting from position, we want to leave the same value as initial one - position.

Hope that it makes any sense 🤭.

Similarily, we can do the same with the scale. The difference would be only the outputRange in our interpolation helper:

Card Interpolation

Thanks to this interpolation, we can map a certain range of values to a completely different one. How it works? Let’s also say we animate from 0 to 1. Then, we could transform this range to e.g. 100 - 50. When at the start of an animation, the value is equal 0, the interpolation will actually output 100. When at 0.5 for example, the interpolation would output 50. I hope you can get the idea.

And mapping of ranges is not stopped nor blocked at the ends of a given range, but goes beyond keeping the proper ratio. We can change this though, by setting the Extrapolation value in the interpolation settings (e.g. CLAMP, meaning that the values that exceeds the range of the input or are below the range, will stay the same.).

For scale in our styles, we additionally added this extrapolation property so that the values will clamp and won’t go any further than they are supposed to.

There is one more minor thing that we did:

const firstItemSwipeDownTranslationBlock = index === 0 ? 50 : prevPosition

In here, we are just exchanging the prevPosition of the first card in the stack to some smaller value so we could get this slower-ish kind of a swipe.

Let’s see how everything works and feels like in the simulator now:

Upwards slide Downwards slide

Great progress! The next thing on our todo list would be the snapping and current card changing.

So far, our code in the return block looks like this:

/src/animations/cardSwipe.ts

return block([
  cond(
    or(eq(dragState, State.ACTIVE), eq(dragState, State.BEGAN)),
    [stopClock(clock), add(translationY, state.position)],
    [translationY],
  ),
])

Let’s remove the translationY and instead do this:

return block([
  cond(
    or(eq(dragState, State.ACTIVE), eq(dragState, State.BEGAN)),
    [stopClock(clock), add(translationY, state.position)],
    [
      cond(clockRunning(clock), 0, [
        set(state.finished, 0),
        set(state.time, 0),
        set(state.velocity, velocityY),
        set(state.position, add(translationY, state.position)),
      ]),
    ],
  ),
])

The code that we added resets the animation by updating some of the state properties to be falsy. It also sets the velocity and the initial position for our animation which is exactly the same as in the if node (second parameter in the cond function). And finally, all this stuff should be fired up only when the clock is not currently running.

Another thing would be to update the activeIndex of the cards based on the velocity and the translationY value. Let’s code it and assign the threshold that we will use in our logic to a variable outside of the scope of our animating function and set it to, let’s also say 500 (feel free to mess around with the settings to your liking if you want 😎):

/src/animations/cardSwipe.ts

...

const VELOCITY_TRESHOLD = 500;

export const cardSwipe = (
  translationY: Animated.Value<number>,
  dragState: Animated.Value<State>,

...

Let’s also handle a case for increasing the index of the current card. The important thing here would be the constraints for the setter - as stated above, we should check the velocity and translateY values and check them against this previously defined threshold value and the card height. We also have to check if the current value of the index is less than the index of the last card in the stack.

Code:

/src/animations/cardSwipe.ts

  cond(
    or(
      and(
        lessThan(activeIndex, maxIndex),
        lessThan(translationY, -cardHeight * 0.25),
      ),
      and(
        lessThan(activeIndex, maxIndex),
        lessThan(velocityY, -VELOCITY_THRESHOLD),
      ),
    ),
    [
      set(activeIndex, add(activeIndex, 1)),
    ],
  ),

Notice that we used some reanimated util functions to evaluate if the value is less or greater than the other or to do addition. If the active card index is less than the maximum index value AND either the velocity of the swipe or the scrolled distance exceeds set thresholds, we can evaluate the node passed as the second argument to the cond util. This node task is to increase the activeIndex by one (set for setting the value and add to add one to the passed value).

Also, please notice that we have to take into consideration the fact, that the velocity might be a negative number 🙃.

Another condition block should cover the activeIndex decrease (with sub util instead of the add and greaterThan instead of lessThan) which is pretty similar to the previous one:

/src/animations/cardSwipe.ts

  cond(
    or(
      and(
        greaterThan(activeIndex, 0),
        greaterThan(translationY, cardHeight * 0.25),
      ),
      and(
        greaterThan(activeIndex, 0),
        greaterThan(velocityY, VELOCITY_THRESHOLD),
      ),
    ),
    [
      set(activeIndex, sub(activeIndex, 1)),
    ],
  ),

Now, that our two blocks are ready, we need to fire them up only when the touch gesture is in the END state. Otherwise, the animation would be firing up endlessly over and over again in a loop.

To embelish the user experince, we can also add another constraint of 50 pixels threshold for the translationY value.

Let’s wrap our two condition blocks then, just like so:

/src/animations/cardSwipe.ts

  cond(
    and(eq(dragState, State.END), greaterThan(abs(translationY), 50)),
    [
      cond(
        or(
          and(
            lessThan(activeIndex, maxIndex),
            lessThan(translationY, -cardHeight * 0.25),
          ),
          and(
            lessThan(activeIndex, maxIndex),
            lessThan(velocityY, -VELOCITY_THRESHOLD),
          ),
        ),
        [set(activeIndex, add(activeIndex, 1))],
      ),
      cond(
        or(
          and(
            greaterThan(activeIndex, 0),
            greaterThan(translationY, cardHeight * 0.25),
          ),
          and(
            greaterThan(activeIndex, 0),
            greaterThan(velocityY, VELOCITY_THRESHOLD),
          ),
        ),
        [set(activeIndex, sub(activeIndex, 1))],
      ),
    ],
  ),

translationY is wrapped with abs helper as we want to check whether it exceeds the threshold in both directions.

After handling our setters and having the value of activeIndex updated, we can now set the destination value of our animation, being simply a multiplied card height by the value of (yes, you guessed it) activeIndex. Put the code responsible for it just below our block with the setters:

/src/animations/cardSwipe.ts

set(config.toValue, multiply(activeIndex, -cardHeight)),

cardHeight must be a negative number, otherwise the animation would go in reverse 😎

We are now getting closer and closer to achieving the final scroll animation. What we still got to do is:

  • Starting the animation by firing up the clock and passing it to the spring function
  • Reseting the velocityY and translationY values
  • Stopping the clock after the animation is finished and returning the position of the animation

As for the first point:

/src/animations/cardSwipe.ts

  cond(
    or(
      eq(dragState, State.UNDETERMINED),
      greaterThan(abs(translationY), 0),
    ),
    [startClock(clock)],
  ),

A clock should be started whenever we swipe, thus greaterThan(abs(translationY), 0).

If we were to start the animation at the very first render (e.g. if we would want to animate for a given index for some reason), we would want this condition to pass: eq(dragState, State.UNDETERMINED). For purposes of this article we basically don’t need it. Feel free to delete if you do not need it in your use case 🤖

Let’s also reset the aforementioned values (second point):

/src/animations/cardSwipe.ts

  set(translationY, 0),
  set(velocityY, 0),

and stop the clock along with returning a state.position (third point):

/src/animations/cardSwipe.ts

  spring(clock, state, config),
  cond(state.finished, stopClock(clock)),
  state.position,

Our finished animation logic should look like this 👨🏽‍🏫:

/src/animations/cardSwipe.ts

const VELOCITY_THRESHOLD = 500

export const cardSwipe = (
  translationY: Animated.Value<number>,
  dragState: Animated.Value<State>,
  velocityY: Animated.Value<number>,
  activeIndex: Animated.Value<number>,
  maxIndex: number,
  clock: Animated.Clock,
  cardHeight: number,
) => {
  const state = {
    finished: new Value(0),
    velocity: new Value(0),
    position: new Value(0),
    time: new Value(0),
  }

  const config = {
    damping: 12,
    tension: 1,
    friction: 2,
    mass: 0.2,
    stiffness: 121.6,
    overshootClamping: false,
    restSpeedThreshold: 0.001,
    restDisplacementThreshold: 0.001,
    toValue: new Value(0),
  }

  return block([
    cond(
      or(eq(dragState, State.ACTIVE), eq(dragState, State.BEGAN)),
      [stopClock(clock), add(translationY, state.position)],
      [
        cond(clockRunning(clock), 0, [
          set(state.finished, 0),
          set(state.time, 0),
          set(state.velocity, velocityY),
          set(state.position, add(translationY, state.position)),

          cond(
            and(eq(dragState, State.END), greaterThan(abs(translationY), 50)),
            [
              cond(
                or(
                  and(
                    lessThan(activeIndex, maxIndex),
                    lessThan(translationY, -cardHeight * 0.25),
                  ),
                  and(
                    lessThan(activeIndex, maxIndex),
                    lessThan(velocityY, -VELOCITY_THRESHOLD),
                  ),
                ),
                [set(activeIndex, add(activeIndex, 1))],
              ),
              cond(
                or(
                  and(
                    greaterThan(activeIndex, 0),
                    greaterThan(translationY, cardHeight * 0.25),
                  ),
                  and(
                    greaterThan(activeIndex, 0),
                    greaterThan(velocityY, VELOCITY_THRESHOLD),
                  ),
                ),
                [set(activeIndex, sub(activeIndex, 1))],
              ),
            ],
          ),

          set(config.toValue, multiply(activeIndex, -cardHeight)),
          cond(
            or(
              eq(dragState, State.UNDETERMINED),
              greaterThan(abs(translationY), 0),
            ),
            [startClock(clock)],
          ),

          set(translationY, 0),
          set(velocityY, 0),
        ]),

        spring(clock, state, config),
        cond(state.finished, stopClock(clock)),
        state.position,
      ],
    ),
  ])
}

Let’s save our progess, restart the app and check how it looks in the simulator/on the phone 🔥:

Final animation

You can find the final code for it in the repo here!

I hope that you found this article at least a bit helpful and learned a bit about the reanimated library. Hopefully it was fun 👨🏽‍🏫

Feel free to re-use some of the code and if you fancy some more reading on the topic of animations in React Native, then check out the rest of our blog 👋🏽


Written by Daniel Grychtoł.