The Widlarz Group Blog

Recreating the OLX animation with React Native and Reanimated library

July 01, 2020

react native

mobile

reanimated

animations

The article uses the version one of Reanimated API. Version 2.0 has just been released with quite a major changes, keep that in mind 🙂!

The Reanimated v1 API uses DSL (while v2 not) that has quite a learning curve and uses this util functions to write the animation logic (e.g. call, cond, eq etc.).

Lately, the OLX (popular shopping app/service in Poland), updated its mobile app. When I first launched the renewed version, I was greeted by this cool welcome screen that had this nice scroll animation, transitioning between two “states”. I really liked it and decided to try and recreate it with React Native Reanimated. By the way, I wonder what stack is being used by the OLX team to build their apps. Are they built natively or maybe with React Native or Flutter…

Nevertheless, let’s start!

Below, you can see the original animation as well as its finished and recreated version from this article.

The source code for the finished animation is available on the master branch here

Starter project

In order to make things faster, I setup a starter project with some pre-made components, assets and fonts as well as all the necessary packages like reanimated or gesture-handler installed.

Here is the link

When you run it, it should look like this:

starter

As you can tell, all the components are styled, we have a custom font which is similar to the one used in the original and this yellow X pic thingy is also added to the app 💪🏽. All we have to do now, is to handle the animation.

Let’s plan our work

Before we start coding the actual animation, let’s stop and think for a while about what we need to do.

By looking at the original animation, we can tell that the user should be able to scroll the screen on the Y-axis. Based on the current scroll position, we fade in or fade out in-between the screens or states (for the sake of the article, let’s just call them states). When the scroll value reaches (or not) / exceeds a given threshold, the screen should either spring back to the previous position or forward to the next state. Additionally, springing through the states should be triggered not only by the scroll position, but also by the scroll velocity - whenever we swipe up or down more vividly, the transition should also be invoked as well.

In order to achieve it, we can use the PanGestureHandler which gives us access to the current scroll/translation position on both X and Y-axis as well as all the data about whether we finished the gesture event or not and what was its velocity. Just all we need 🔥

On every scroll event, we need to save all the data about it and pass it to our animation function. It will handle all the logic and will return the animated value which we then can use to animate all the components.

What attributes do we need to animate?

We need to animate the translation on Y-axis, opacity, scale and background color style properties.

Right off the bat, we can see that some of the values should range from 0 to 1, some from let’s say -700 to 700 and some in yet different range (e.g. colors).

In such a case, we can use interpolation. This way, based on one animated value, we can easily create more 😀.

Interpolation itself, gives us the ability to translate or transform one values range into the other. We can translate e.g. our Y-axis position into the colors* or reverse the animation, or start animating something from the middle. The options are endless.

* as I am writing this article, the reanimated team is currently working on interpolating the colors (this PR). In this article we will use react-native-redash to easily interpolate between two or more colors.

To summarize it all, here’s a short to-do list:

  1. Setup of PanGestureHandler - translating the elements in Y-axis based on the scroll/drag value
  2. Springing back to the initial position whenever the scroll/drag gesture is finished
  3. Add conditional springing based on the custom threshold value (either to expanded state or collapsed state)
  4. “Slowing down” the scroll/drag
  5. Animating the opacity - interpolating the initial animation node
  6. Reversing the opacity animation
  7. Reversing and diminishing the translation on Y-axis for the second screen/state
  8. Animating the scaling
  9. Animating the background - opacity and colors

Now, that we more or less defined and scheduled our work, let’s get our hands (or should I say keyboards :laughing: :keyboard:) dirty and get down to work!

Handling the drag/scroll gesture

As planned, let’s start with the brain 🧠 of our animation - drag/scroll gesture.

In the starter project, there is already a PanGestureHandler imported and added to our screen (AnimatedScreen.tsx)

As mentioned before, we want to save the data about the translation on Y-axis as well as the gesture state (whether it was finished = user released the finger and swipe is finished) somewhere. The best way to do it, is to use either useState or useRef hooks when working with functional components (useMemo also should work 🔥). We don’t want to re-instantiate the values on every render.

We know where to save. But how can we do it? With an event helper function that maps the values from e.g. gesture handlers to the animated nodes! We can import it from react-native-reanimated and pass it to PanGestureHandler.

onGestureEvent and onHandlerStateChange props from PanGestureHandler will output data about an event on every change. If we were to pass a simple function to these props that simply consol-logs out the data:

...
      <PanGestureHandler
        onGestureEvent={(event: PanGestureHandlerGestureEvent) =>
          console.log(event.nativeEvent)
        }
        onHandlerStateChange={(event: PanGestureHandlerStateChangeEvent) =>
          console.log(event.nativeEvent)
        }>
...

we would get something like this in our console:

panGestureEventData

We can see that among these various values we have the ones needed for our animation - state, velocity and translationY.

If you are wondering what this 4 or 5 value of state means, it is simply telling us whether the gesture was started/finished/canceled or still lasts. E.g. 5 stands for finished.

Now that we know how the event data is structured, we can get rid of these console.logs and create our event handler.

Let’s import it and set it up:

import {event, Value} from 'react-native-reanimated'

...

const AnimatedScreen = () => {
  const [dragY] = useState(new Value(0));
  const [velocity] = useState(new Value(0));
  const [dragState] = useState(new Value(0));

  const dragHandler = event([
    {
      nativeEvent: {
        translationY: dragY,
        state: dragState,
        velocityY: velocity,
      },
    },
  ]);

...

We used useState hook to store the data and configured our event handler. This event function takes in an array as an argument with the object that tells it what values should be mapped to which variables. Now we can pass it down to both onGestureEvent and onHandlerStateChange props in our gesture handler:

...

      <Image
        resizeMode="contain"
        style={styles.xBg}
        source={require('../assets/xBg.png')}
      />

      <PanGestureHandler
        onGestureEvent={dragHandler}
        onHandlerStateChange={dragHandler}>
        <Animated.View style={styles.scrollBox}>
          <Animated.View style={styles.primaryScreen}>
            <Title>{"Hello there!\nIt's me, animation!"}</Title>

...

Let’s quickly test whether everything is working as it should by transforming part of our UI - primaryScreen. Let’s update the style prop on it by passing the animated value dragY to transform property:

...
    <Animated.View
        style={[styles.primaryScreen, {transform: [{translateY: dragY}]}]}>
        <Title>{"Hello there!\nIt's me, animation!"}</Title>
...

When animating Views we have to remember to use the ones from react-native-reanimated

As we can see, the View is being translated. It resets whenever we start the next gesture though. Let’s handle it now.

Springing back to the initial position whenever the scroll/drag gestures is finished

The next step in recreating our animation would be to tackle this springing back to the initial position after the drag gesture is finished.

In order to do so, we need to declare our animation and actually run it.

The way it works with reanimated is that we need to use something called a Clock.

To give it a closer look, at least theoretically, feel free to dive into the official docs here

Based on the drag and state value, we are able to e.g. animate our drag value back to zero whenever we finish our gesture. To do so, we need to “create” animated nodes. To write some animation logic, we need to use these helper functions from the library (e.g. cond instead of if/else statement). I strongly (again 😀) recommend visiting the docs if something is not as clear as it should.

Let’s create our function that will return the animation - an animated node. We will use the block util function imported from reanimated library. It takes an array of nodes, and handles them by the order in which they are located within this array. In the end, it returns the last element from the array.

Each node inside this array, can do different stuff, e.g. we can check current touch state or translation value and edit the config object of our animation.

Outside the scope of our animated component, let’s create our function and make it return this block.

const runSpring = () => {
  return block([200])
}

...

const AnimatedScreen = () => {
  const [dragY] = useState(new Value(0));

...

and assign the returned data to a variable inside our component:

const AnimatedScreen = () => {
  ...

  const animation = runSpring()

  ...

And right now, if we were to exchange the transform property in our Animated.View we setup earlier with this new value, it would be moved down by 200 points.

...
    <PanGestureHandler
      onGestureEvent={dragHandler}
      onHandlerStateChange={dragHandler}>
      <Animated.View style={styles.scrollBox}>
        <Animated.View
          style={[
            styles.primaryScreen,
            {transform: [{translateY: animation}]},
          ]}>
          <Title>{"Hello there!\nIt's me, animation!"}</Title>
...

Let’s now pass our drag value to this function and make it return it:

const animation = runSpring(dragY)
const runSpring = (value: Animated.Value<number>) => {
  return block([value])
}

Now, we did not really change anything but added additional lines of code. In a moment, we will significantly embelish our animation though 🔥.

As stated at the very beginning of this paragraph, let’s add this feature that whenever we finish the gesture, the Views spring back to initial position.

In order to do it, we need to handle few things:

  1. Translate the components on Y-axis when we drag our PanGestureHandler
  2. Start the spring animation whenever we finish the gesture to initial position (also setting the current position of the element)
  3. Stop the animation if we try to drag the element again while the animation is running.

As for the first one, we are pretty much done, let’s say. We will tweak everything though later on.

As for the springing back, we need to start with adding two setting objects - state and config of the animation. The first tells us about the (yeah, you guessed it 😅) state of our animation, being whether the animation is finished, what’s the current value of the animated node etc. The latter on the other hand, is handling the behavior of the animation - we can pass to it the velocity of the gesture, assign the target value to which we want to animate or configure the spring strength by tweaking appropriate values such as stiffness, damping or mass (give it a proper look here).

Inside the scope of our animating function runSpring, let’s add these two objects:

const runSpring = (value: Animated.Value<number>) => {
  const state = {
    finished: new Value(0),
    velocity: new Value(0),
    position: new Value(0),
    time: new Value(0),
  }

  const config = {
    toValue: new Value(0),
    damping: 10,
    mass: 0.4,
    stiffness: 50,
    overshootClamping: false,
    restSpeedThreshold: 0.001,
    restDisplacementThreshold: 0.001,
  }

  return block([value])
}

Our state object has some default values setup. The finished value tells us whether the animation is finished, that it has reached the destination value. If so, it will evaluate to 1, if not, to 0.

Velocity is the place where we can pass the velocity from our gesture handler so that our animation is coherent in terms of the strength with which we scroll.

Position is our starting point. We can e.g. animate from 0 to 1, or from 0 to 100, or from 200 to 400. Whatever you want 😎.

As for the config object, we have the toValue property (described above) as well as the other ones that are responsible for how the animation behaves. To be honest, as I am writing this article, I don’t fully understand each of these properties. Whenever I animate, I just mess around with them until I find the perfect combination that matches my expectations 🙈.

To fire the back-springing animation, we need to properly configure everything and start it in the appropiate moment (that is, when the gesture is over).

In order to do so, we can use these helper functions from reanimated: eq, cond, set, spring and clockRunning. They will help us write conditional statements, start the animation and check whether the animation is already running.

Our animating function has to take a few more parameters now - apart from translation value, we need to pass to it the information about the drag state, velocity and an instance of a Clock.

Let’s instantiate the clock first:

const AnimatedScreen = () => {
  ...
  const [clock] = useState(new Clock());
  ...

Now, we can edit the arguments of runSpring:

const runSpring = (
  value: Animated.Value<number>,
  vel: Animated.Adaptable<number>,
  clock: Animated.Clock,
  dragState: Animated.Value<number>,
) => {
  const state = {
  ...

and pass everything to the function:

const animation = runSpring(dragY, velocity, clock, dragState)

Let’s now edit our block in runSpring function.

We should first check whether the gesture is finished. If not, we will simply return the translation value from our gesture handler:

return block([cond(eq(dragState, State.END), [], [value])])
State is an enum imported from react-native-gesture-handler.
cond function takes three arguments. First one is the condition (something that can be truthy or falsy). Second one is the block that will be fired when condition is “fulfilled” while the last one when it’s not. Third one is also optional.

Whenever the drag reaches the END state, the code/nodes in the first array will be fired. Else, the function will return the translation value.

As of now, the first array is empty, so let’s add all the logic inside it, shall we?

return block([
  cond(
    eq(dragState, State.END),
    [
      cond(
        clockRunning(clock),
        [],
        [
          set(state.finished, 0),
          set(state.time, 0),
          set(state.position, value),
          set(state.velocity, vel),
          set(config.toValue, 0),
          startClock(clock),
        ]
      ),
      cond(state.finished, stopClock(clock)),
      spring(clock, state, config),
      state.position,
    ],
    [value]
  ),
])

We added one additional check to tell whether the other animation is already running as we want to trigger the animation only when it’s not. If it is, the first array (which is empty, thus no action triggered) will be evaluated. Else, the second one. And this is the place where we will setup and fire the springin-back movement.

We have to set the finished property of the state of the animation to 0 (as you probably remember from previous paragraphs, 0 ➡️ not finished). We should also do the same with time property.

The state.position is simply the starting point of our animation. It should be our current translation value, thus we simply set it to our value (coming from gesture handler). velocity property should take the current velocity of the gesture handler.

And as for the animation destination, we have to set the toValue of the config object to the desired value. In our case, it is 0 for now.

All the updating is done with this set helper function.

When the values assignments are finished, we can start the clock with another helper function startClock and run the animation with spring (it will be updating our position) which we have to provide with our clock, state and config data (notice that it is fired outside of the block with our setters).

Additionaly, we have to also stop the clock whenever the animation is finished (finished property):

cond(state.finished, stopClock(clock)),
1 is truthy, 0 is not

At the end of our array, there’s this state.position value. It means that we are returning it from the animated node, as it is the last element from our block.

Let’s see how it looks now:

Works fine. There is one slight problem though. Whenever we try swipe again before the animation is finished, the animation breaks as we are already animating. To fix this, we just have to stop the clock whenever we start the drag gesture:

return block([
  cond(or(eq(dragState, State.BEGAN), eq(dragState, State.ACTIVE)), [
    stopClock(clock),
  ]),
  cond(
    eq(dragState, State.END),
    [
  ...

With this being fixed, we can now try swiping again and again and nothing will break:

Code for this step here

Adding second state (expanded)

Our animation is supposed to transition between these two steps - being either expanded or collapsed.

In other words, our swipe gesture should, at one point (e.g. when exceeding a certain length threshold), animate not back to 0 being the initial position, but to some other value representing the expanded state.

Let’s assume then, that:

  1. Whenever we swipe e.g. 30% of the screen height
  2. Whenever the swipe velocity exceeds a certain threshold

the screen would be animated to the second state and vice versa. When none of these conditions were met, then the animation would “reset” itself and spring back to the previous state.

With this in mind, let’s add some config variables that would be of a little help when writing our animation logic:

const { height } = Dimensions.get("window")

const expandedTarget = -height * 0.3
const dragTreshold = -height * 0.3
const velocityTrigerringThreshold = -2000

We declared the threshold for swipe distance as well as the minimal velocity for firing the transition to the second state. We also added the expandedTarget variable ➡️ this is the value to which we will be animating while switching to the second state.

Before we dive back to the runSpring, let’s quickly add one more animated node. It would be the one indicating this second stage. After every gesture is finished, this new value would be animated either to 0 or to let’s say 30% of height of the screen, while the value that is mapped to gesture handler, would go back to 0 always. That being said, whenever we return from our runSpring the transition value, we have to combine it with the new one.

It is fairly easy, we just have to use another util function from reanimated ➡️ add.

In code:

// adding new animated value:
...
const AnimatedScreen = () => {
  const [dragY] = useState(new Value(0));
  const [dragCompensator] = useState(new Value(0));
  const [velocity] = useState(new Value(0));
  const [dragState] = useState(new Value(0));
  const [clock] = useState(new Clock());

  const animation = runSpring(dragY, dragCompensator, velocity, clock, dragState);
...

and our return of runSpring:

  ...
  return block([
    cond(or(eq(dragState, State.BEGAN), eq(dragState, State.ACTIVE)), [
      stopClock(clock),
    ]),
    cond(
      eq(dragState, State.END),
      [
        cond(
          clockRunning(clock),
          [],
          [
            set(state.finished, 0),
            set(state.time, 0),
            set(state.position, add(value, dragCompensator)),
            set(state.velocity, vel),
            set(config.toValue, 0),
            startClock(clock),
          ],
        ),
        cond(state.finished, stopClock(clock)),
        spring(clock, state, config),
        state.position,
      ],
      [add(value, dragCompensator)],
    ),
  ]);
  ...

Notice, that the addition of these two values takes action in two places, at the end of our block, as well as in the setter for the animation starting position.

Now, the only thing we have to do, is to write the remaining animation logic.

Our statements in JS would look like this:

if (swippedEnoughUpwards || upwardSwipeVelocityExceeded) {
  // config for animation going to the expanded state
}

if (swippedEnoughDownwards || downwardSwipeVelocityExceeded) {
  // config for animation going to the initial state
}

As you know, with reanimated, we have to use their custom helper functions in order to write any logic. Apart from using cond or eq like before, we’ll pick also or, and, neq, greaterThan and lessThan💪🏽. Given their names, they should be self-explainatory 😀.

The next step would be to override our config that we set in one of our blocks in runSpring function. Whenever a condition is fulfilled, we would change and re-set some config value.

Let’s start with our first statement and write it with reanimated:

  ...
    set(state.velocity, vel),
    set(config.toValue, 0),
    cond(
      or(
        lessThan(vel, velocityTrigerringThreshold),
        and(eq(dragCompensator, 0), lessThan(value, dragTreshold)),
      ),
      [
        set(config.toValue, expandedTarget),
        set(dragCompensator, expandedTarget),
      ],
    ),
    startClock(clock),
  ...
cond(state.finished, stopClock(clock)),

Whenever we scroll down, the velocity of the gesture will always be velocity <= 0. The same is with the translation value on Y axis. That is why we check whether the velocity (vel) and translation value (value) is less than the values of thresholds. Also, or util was used, as we need only one of these conditions to be true in order to update the animation settings.

As for the setters, there are two ➡️ updating destination (.toValue) and the second one updating our compensator (dragCompensator). expandedTarget is the variable we defined earlier 😀

Now it’s time to do the same for the other transition, from expanded state to the collapsed/initial state.

The conditional statement will be just a tad different ➡️ instead of lessThan we will use greaterThan + the thresholds should be absolute values (another util function from reanimated - abs).

The code for the second condition:

  ...
    cond(
      or(
        greaterThan(vel, abs(velocityTrigerringThreshold)),
        and(
          neq(dragCompensator, 0),
          greaterThan(value, abs(dragTreshold)),
        ),
      ),
      [set(config.toValue, 0), set(dragCompensator, 0)],
    ),
  ...

Situation is similar, we are re-setting the same two values as before, but to different values.

And our whole animating function:

const runSpring = (
  value: Animated.Value<number>,
  dragCompensator: Animated.Value<number>,
  vel: Animated.Adaptable<number>,
  clock: Animated.Clock,
  dragState: Animated.Value<number>
) => {
  const state = {
    finished: new Value(0),
    velocity: new Value(0),
    position: new Value(0),
    time: new Value(0),
  }

  const config = {
    toValue: new Value(0),
    damping: 10,
    mass: 0.4,
    stiffness: 50,
    overshootClamping: false,
    restSpeedThreshold: 0.001,
    restDisplacementThreshold: 0.001,
  }

  return block([
    cond(or(eq(dragState, State.BEGAN), eq(dragState, State.ACTIVE)), [
      stopClock(clock),
    ]),
    cond(
      eq(dragState, State.END),
      [
        cond(
          clockRunning(clock),
          [],
          [
            set(state.finished, 0),
            set(state.time, 0),
            set(state.position, add(value, dragCompensator)),
            set(state.velocity, vel),
            set(config.toValue, 0),
            cond(
              or(
                lessThan(vel, velocityTrigerringThreshold),
                and(eq(dragCompensator, 0), lessThan(value, dragTreshold))
              ),
              [
                set(config.toValue, expandedTarget),
                set(dragCompensator, expandedTarget),
              ]
            ),
            cond(
              or(
                greaterThan(vel, abs(velocityTrigerringThreshold)),
                and(
                  neq(dragCompensator, 0),
                  greaterThan(value, abs(dragTreshold))
                )
              ),
              [set(config.toValue, 0), set(dragCompensator, 0)]
            ),
            startClock(clock),
          ]
        ),
        cond(state.finished, stopClock(clock)),
        spring(clock, state, config),
        state.position,
      ],
      [add(value, dragCompensator)]
    ),
  ])
}

Let’s give our animation a testing spin now and see if we are missing something:

As we can see, the transitioning more or less works. On every drag that exceeds either velocity or translation on Y axis threshold triggers the repositioning.

There is a small glitch though. Notice that whenever the animation is “expanded”, any touch will animate back to 0, not to the current dragCompensator value. To fix it, let’s just update one setter right before our two condition blocks:

  ...
    set(state.position, add(value, dragCompensator)),
    set(state.velocity, vel),
    set(config.toValue, dragCompensator),
    cond(
      or(
        lessThan(vel, velocityTrigerringThreshold),
        and(eq(dragCompensator, 0), lessThan(value, dragTreshold)),
      ),
      [
        set(config.toValue, expandedTarget),
        set(dragCompensator, expandedTarget),
      ],
    ),
  ...

And the animation with everything working properly:

Code for this step here

Slowing down the drag

When messing around with original animation, I noticed that the drag is significantly slowed down. Let’s give the same behavior to our React Native duplicate also!

To achieve this slow drag effect, we can use the interpolation.

The goal of interpolation is to map a given set of input values to a given set of output values (Reanimated Docs: interpolate).

Thanks to this, we can e.g. block or ignore some ranges, or create a copy of an animated node but reverse it etc. In fact, the rest of the animation is going to be just “copying” what our runSpring function returns and interpolating it 🔥!

Now back to the animation!

In our runSpring function there are two places that are “responsible” for the translating on Y axis ➡️ the very last array in block and one setter that updates the starting position of the animation. What we need to do now, is to interpolate it down by, let’s say, 400% -> map a value 4 times smaller to every one that is being inputted.

The below code should probably make more sense 🤣:

  interpolate(value, {
    inputRange: [-2, 2],
    outputRange: [-0.5, 0.5],
  }),

We do not necessarily have to worry about the ranges in this case, only about the proportions. It could also be [-1, 1] -> [-0.25, 0.25] etc.

Let’s combine it with our add util (in two places, last array and the setter for state.position):

  ...
  return block([
    cond(or(eq(dragState, State.BEGAN), eq(dragState, State.ACTIVE)), [
      stopClock(clock),
    ]),
    cond(
      eq(dragState, State.END),
      [
        cond(
          clockRunning(clock),
          [],
          [
            set(state.finished, 0),
            set(state.time, 0),
            set(
              state.position,
              add(
                dragCompensator,
                interpolate(value, {
                  inputRange: [-2, 2],
                  outputRange: [-0.5, 0.5],
                }),
              ),
            ),
            set(state.velocity, vel),
            set(config.toValue, dragCompensator),
            cond(
              or(
                lessThan(vel, velocityTrigerringThreshold),
                and(eq(dragCompensator, 0), lessThan(value, dragTreshold)),
              ),
              [
                set(config.toValue, expandedTarget),
                set(dragCompensator, expandedTarget),
              ],
            ),
            cond(
              or(
                greaterThan(vel, abs(velocityTrigerringThreshold)),
                and(
                  neq(dragCompensator, 0),
                  greaterThan(value, abs(dragTreshold)),
                ),
              ),
              [set(config.toValue, 0), set(dragCompensator, 0)],
            ),
            startClock(clock),
          ],
        ),
        cond(state.finished, stopClock(clock)),
        spring(clock, state, config),
        state.position,
      ],
      [
        add(
          dragCompensator,
          interpolate(value, {
            inputRange: [-2, 2],
            outputRange: [-0.5, 0.5],
          }),
        ),
      ],
    ),
  ]);
  ...

Our drag gesture should now be slowed down:

Code for this step here

Interpolating the remaining animation values

Now, we have to tackle the rest of the animation, and as I said before, it is just a matter of some interpolation 👩🏽‍💻

Moving the secondary screen

The second/expanded state should display this second paragraph with another title. We want it to be reversed ➡️ whenever the value returned from runSpring equals to the initial position (being 0), the second screen should equal to the expanded value. This expanded value is our target to which we animate and compensate with dragCompensator (expandedTarget).

So, to reverse with interpolation:

interpolate(SOME_VALUE, {
  inputRange: [0, 1],
  outputRange: [1, 0],
})

Important thing to know is that the values in inputRange has to be in increasing order.

Let’s try to reverse our runSpring then:

const animationReversed = interpolate(animation, {
  inputRange: [expandedTarget, 0],
  outputRange: [0, -expandedTarget],
})

And add this animationReversed animated node to the styles object of our second “screen”:

  ...
    <Animated.View
      style={[
        styles.secondaryScreen,
        {transform: [{translateY: animationReversed}]},
      ]}>
      <Title>I was transitioned</Title>

      <CustomText>
        {`There it is.\nA second paragraph\nin this happy little animation!`}
      </CustomText>

      <Button>Click me now</Button>
    </Animated.View>
  ...

How it looks:

Seems to work as expected. In the original though, it can be noticed that this secondary “screen” is revealing itself as if it was behind the first one. This small tweak should help:

const animationReversed = interpolate(animation, {
  inputRange: [expandedTarget, 0],
  outputRange: [0, -expandedTarget * 0.5],
})

It is better now, isn’t it?

Opacity of the elements

Both screens should not be visible at the same time, thus we need to hide/show them somehow. Let’s do so by animating the opacity of both.

We need two “versions” of opacity animated nodes, one reversed and one not. Let’s create them with interpolation:

const opacity = interpolate(animation, {
  inputRange: [expandedTarget, 0],
  outputRange: [1, 0],
})

const opacityReversed = interpolate(animation, {
  inputRange: [expandedTarget, 0],
  outputRange: [0, 1],
})

Similarly to the reversion thingy that we did with animationReversed, the opacities are interpolated based on the output of runSpring.

Let’s add these values to the styles objects (don’t forget about the big circle component and this “X” image 😀):

  ...
  return (
    <View style={styles.content}>
      <CircleBg opacity={opacityReversed} />

      <Animated.Image
        resizeMode="contain"
        style={[styles.xBg, {opacity}]}
        source={require('../assets/xBg.png')}
      />

      <PanGestureHandler
        onGestureEvent={dragHandler}
        onHandlerStateChange={dragHandler}>
        <Animated.View style={styles.scrollBox}>
          <Animated.View
            style={[
              styles.primaryScreen,
              {transform: [{translateY: animation}], opacity: opacityReversed},
            ]}>
            <Title>{"Hello there!\nIt's me, animation!"}</Title>

            <CustomText>
              {`See it finished in your mind before\nyou ever start. I thought today \nwe would do a happy\nlittle animation!`}
            </CustomText>

            <ArrowDown />
          </Animated.View>

          <Animated.View
            style={[
              styles.secondaryScreen,
              {transform: [{translateY: animationReversed}], opacity},
            ]}>
            <Title>I was transitioned</Title>

            <CustomText>
              {`There it is.\nA second paragraph\nin this happy little animation!`}
            </CustomText>

            <Button>Click me now</Button>
          </Animated.View>
        </Animated.View>
      </PanGestureHandler>
    </View>
  );
  ...

Our CircleBg component needs some tweaking also:

import React from "react"
import { StyleSheet, Dimensions } from "react-native"
import Animated from "react-native-reanimated"
import { theme } from "../Theme"

const { height } = Dimensions.get("window")

const bgDimension = height
const borderWidth = height * 0.25

interface Props {
  opacity: Animated.Node<number>;
}

const CircleBg = ({ opacity }: Props) => {
  return <Animated.View style={[styles.circleOuter, { opacity }]} />
}

const styles = StyleSheet.create({
  circleOuter: {
    position: "absolute",
    width: bgDimension,
    height: bgDimension,
    borderRadius: bgDimension,
    borderWidth: borderWidth,
    borderColor: theme.colors.blue,
    backgroundColor: "transparent",
  },
})

export default CircleBg

And the results of our happy little interpolation:

Code that we have so far here

Scale interpolating

Our image of an “X” needs to be scaling up and down. Similarly as with opacity, we need to do this:

const scale = interpolate(animation, {
  inputRange: [expandedTarget, 0],
  outputRange: [1, 0],
})

and this:

<Animated.Image
  resizeMode="contain"
  style={[styles.xBg, { opacity, transform: [{ scale }] }]}
  source={require("../assets/xBg.png")}
/>

Quick preview:

Background color interpolation

One last thing we have to take care of with this animation, is to change the color of the background.

We could do it e.g. in two ways:

  1. Create two Views with different background colors and change their opacity
  2. Interpolate the color and pass the animated node to the style object

This time we’ll go with the latter. It is really a cool thing to do 🔥.

To easily interpolate between colors, let’s use this react-native-redash library (install it if you haven’t got it already in the project).

Interpolating colors with this library is as easy as with the previous properties we were handling (opacity, scale etc). Just instead of interpolate, we have to use interpolateColor imported from the react-native-redash library:

const backgroundColor = interpolateColor(animation, {
  inputRange: [expandedTarget, 0],
  outputRange: [theme.colors.white, theme.colors.light],
})

*Colors come from the theme object from our starter hello world app.

And let’s also quickly update the return of our screen:

return (
  <Animated.View style={[styles.content, { backgroundColor }]}>
    ...
  </Animated.View>
)

And the animation is ready! Bananas! 🔥💪🏽

Code for current step here

Handling re-renders

There is one more thing worth mentioning - re-renders. With this particular animation we don’t necessarily have to worry about it. But let’s say, that for some strange reason, we need to implement a timer and show the counter on our screens.

Quick implementation:

const [timer, setTimer] = useState(0)

useEffect(() => {
  const timer = setInterval(() => setTimer(prev => prev + 1), 1000)

  return () => {
    clearInterval(timer)
  }
}, [])

and returned content from the component:

return (
  <Animated.View style={[styles.content, { backgroundColor }]}>
    ...
    <Text>{timer}</Text>
    ...
  </Animated.View>
)

Immediately, when we run our animation, we can see that it is gli …i… itchy 🤣:

To prevent from such scenarios, we should wrap all our animated values/nodes in e.g. useMemo hooks, like so:

const animation = useMemo(
  () => runSpring(dragY, dragCompensator, velocity, clock, dragState),
  []
)

const animationReversed = useMemo(
  () =>
    interpolate(animation, {
      inputRange: [expandedTarget, 0],
      outputRange: [0, -expandedTarget * 0.5],
    }),
  []
)

const opacity = useMemo(
  () =>
    interpolate(animation, {
      inputRange: [expandedTarget, 0],
      outputRange: [1, 0],
    }),
  []
)

const opacityReversed = useMemo(
  () =>
    interpolate(animation, {
      inputRange: [expandedTarget, 0],
      outputRange: [0, 1],
    }),
  []
)

const scale = useMemo(
  () =>
    interpolate(animation, {
      inputRange: [expandedTarget, 0],
      outputRange: [1, 0],
    }),
  []
)

const backgroundColor = useMemo(
  () =>
    interpolateColor(animation, {
      inputRange: [expandedTarget, 0],
      outputRange: [theme.colors.white, theme.colors.light],
    }),
  []
)

Now, even though the component is being rerendered every second, the animation is not glitchy and works perfectly:

Let’s get rid of this timer feature as we don’t need it and admire our hard work one more time.

Final outcome:

Summary

It was really fun to implement this animation with reanimated library. I hope that you also had fun reading this article. If it helped you in any way, it is even better!

The code for the finished animation is available here! if you want to fork it for example.


Written by Daniel Grychtoł.