Advanced route transitions with React Transition Group and Popmotion [Part 3]

This is the last part of this tutorial. We will try to implement the drag to close functionality and other little details that will make our prototype even cooler.

David Bismut
10 min readApr 9, 2018

Deprecation note

This tutorial was written more than a year ago. Since then, a lot has happened, Popmotion is now Framer Motion and offer more features. In the meantime, I decided to recreate the same prototype, using react-spring and react-use-gesture. The full commented code is available on codesandbox, and if you’re on a mobile you might want to try the full screen demo. The code follows some of the ideas explained here, but the setup has been simplified, and there’s now an iPad mode.

In Part 2, we’ve successfully animated the entering and exiting transitions when navigating from the home route to the post route. If you’ve followed the previous parts of the tutorial, your code should be similar to my part-2-complete branch.

Drag to close

Drag to close is the next feature we will implement. To do so, we will have to implement the dragging mechanism, and modify some of our exiting transition mechanic.

What we aim for.

Warning: this feature will only work on iPhone (possibly other phones but they weren’t tested). It will not work on desktop browsers.

Let’s decompose the animation first.

  1. The user drags the Post down.
  2. The Post scales down up to a limit.
  3. If the drag continues, the Post closes and the transition executes.

My first instinct was to use a draggable component, but then I realized that I could achieve the same effect with just an onscroll listener. In fact, contrary to desktop browsers, Safari iOS triggers an onscroll event on negative scrolling.

Dragging down is actually the same as scrolling up.

You can see the scroll values on the left being negative when scrolling passed the top threshold.

We’re going to implement several listeners, so let’s create a dedicated method that adds all listeners (let’s call it addListeners). We’re also creating removeListeners, which will remove listeners when the component is unmounted or when the exiting transition starts.

The onscroll listener

Listeners should be added as soon as our entering transition is completed. We’ll be using the onEntered prop from CSSTransition, that triggers this.onEntered as soon as the done callback of addEndListener is called.

In Post.js, add componentWillUnmount, addListeners, removeListeners, onScroll and onEntered methods as below. Make sure you also add the onEntered prop to CSSTransition.

At the moment, the added code simply logs the scroll position of the viewport. However, you might observe that the listener doesn’t work when launching the prototype from the Post page directly.

In fact, when the Post component is not transitioning from the List page, the entering transition doesn’t happen, and neither onEnter or onEntered is called. Fortunately, CSSTransition has an appear prop that when set to true, transitions on the first mount. So add appear as a prop to CSSTransition.

Because we’re now playing the entering transition even when the List page hasn’t been loaded, we need to make sure this.preview exists before progressing any further. So in onEnter, add the condition block below, right after defining this.preview:

if (!this.preview) return;

This will make sure that loading the Post page directly doesn’t render any error.

Implementing onScroll

Great, so now let’s fill the onScroll method. Create three constants right after the imports statements in Post.js:

const DRAG_LIMIT = 50;
const DRAG_THRESHOLD = 100;
const DRAG_MINIMUM_SCALE = 0.8;
// I'm keeping the DRAG verb even though
// we're just scrolling

DRAG_LIMIT represents the negative scroll threshold until when we want the post node to shrink. DRAG_THRESHOLD is the negative scroll threshold that will trigger the exiting navigation. DRAG_MINIMUM_SCALE is the amount that we want our post node to shrink when dragged down.

Next, replace the onScroll method with the code below:

As you can see, Post has a state which has one attribute: dragProgress which will indicates the progress of the drag and will allow scaling the post node accordingly later on.

onScroll is the method called every time the user scrolls: if the scroll is negative but DRAG_THRESHOLD hasn’t been reached yet, dragProgress is updated so that its value interpolates between 0 and 1 when scrolling from 0 to -DRAG_LIMIT.

When the scroll reaches DRAG_THRESHOLD, the exiting transition is triggered by navigating to the home route, and this.isTransitioningFromDrag is set to true to prevent any further scroll action.

When the scroll is null or greater, dragProgress is reset to 0.

Scaling down the post node

Now let’s actually make the post node scale down when it’s dragged down (or better, when scrolling negatively).

Look for the render method, and add the style prop to the post node:

This will make the transform of the post node vary from scale(1) to scale(0.8) and the border-radius from 0 to 16 when this.state.dragProgress varies from 0 to 1.

Let’s run this!

Still some work to do!

What’s wrong? First, corners don’t become round. But most of all there is a very noticeable glitch when the transition starts.

The .post-dragged class

Well again, that’s the same problem we’ve had in the past: the position and overflow need to be fixed and hidden. Let’s use a .post-dragged CSS class that we’ll define in Post.scss and that uses the same rules as .post-enter:

This class needs to be toggled on the Post page when the post node is dragged. We’ll use a package utility called classnames that you can install right now with:

yarn add classnames 

Then add the following import statement at the top of Post.js:

import cn from 'classnames';

In the render function, change the className prop of the first div to:

This activates the .post-dragged class when this.state.dragProgress is greater than 0.

At this point, the exiting transition should look like this:

Animating the scale and fixing the destination position

We’re getting closer, but the destination position isn’t good. There two main reasons for that:

  1. The scale of the post node changes when dragged. Therefore we need to animate it back to 1.
  2. When dragging down / scrolling up, the viewport scroll changes and this messes with the preview node top coordinate (for some reason).

Animating the scale is straightforward: we just need to set a scale property to from style and to style when triggering the exiting transition. Then, the spring in executeExitingTransition will be able to animate the transform.

onExit is where this.from and this.to are set for the exiting transition. Add the scale attribute to this.from as below:

When not dragged, scale will be equal to 1.

Now we need to set the scale for this.to. Add scale: 1 to the returned object of getPreviewStyleAndPosition and we should be good.

In the gist above, you might have noticed that this.to position was updated to compensated for the viewport scroll when the post is dragged, which fixes the second bug.

Run the dragged transition again, and you should have a drag functionality that works ok!

The elastic inertia

As if this wasn’t enough, we’re not done just yet. If you scroll down through the post, and then scroll up so that it reaches the top of the page with inertia, you will get the glitch below:

As you can see, the drag feature is triggered even though the negative scroll was just the effect of the elastic inertia. We will implement two other listeners, ontouchstart and ontouchend, that will make sure the user’s finger is touching the screen before animating the drag to close.

We will implement the following abstract logic:

  • isTouching is a flag that is toggled by ontouchstart and ontouchend events.
  • isDragging is a flag set to true whenever the user touches the screen and a negative scroll is registered.
  • Only when isDragging is set to true will dragProgress be updated.
  • Set isDragging to false when a positive scroll is registered.

Let’s add the listeners first:

Now update onScroll:

And boom, only when your finger is touching the screen should the drag feature activate!

Blurring the background

We want the List page to be blurred in the background when dragging down the post node. To do so, we will use a Safari specific CSS rule called backdrop-filter.

But first, we need to have a div element that will be used as a underlay for our post page.

In the render function of Post.js, add a div right before the post node, with the underlay class name:

Now in Post.scss, add the following rules:

And there you go, you should have a nice background blur on the List page when dragging the post node down.

Adding fades on the underlay and close icon

If you’ve played the entering transition, you’ll see that the underlay appears way too soon. We actually want it to appear only once executeEnteringTransition is fired. Modify executeEnteringTransition, to add a new custom class .post-enter-started to the page node right when the method is triggered, and remove it when the animation is finished right before the done() statement:

In Post.scss, add the following rules (they will also animate and fade the close icon in):

And now we should have close-to-perfect entering and exiting transitions, with the drag to close feature supported!

Fun with Pressure.js

PressureJS is a small library for handling Force Touch and 3D Touch. We’re going to use it so that the Preview component in the List page reacts to Force Touch.

Pressure intensity makes the scale of the preview vary

First, let’s add PressureJS dependency:

yarn add pressure

Then in Preview.js, we’ll be adding a state with the force attribute that will represent the amount of force applied on the screen. In the render function, we’ll reference the root node so that we can initialize PressureJS in componentDidMount. Again in the render function, we’ll scale the root node according to this.state.force.

The code below does exactly that:

I know, this looks overly complex compared to what I’ve described earlier. The reason for the timer, setTimeout, onMouseUp, replacing the onClick prop and creating a custom onClick method lies in the sequence of the events below:

  1. The user presses the preview node.
  2. The state force updates and the node is scaled accordingly.
  3. When the user lifts the finger, the end callback from Pressure is fired and sets a timeout of 50ms before resetting the force state to 0.
  4. If the gesture is recognized as a click, onClick fires just after the end callback has been triggered, so it will cancel resetting the force.

This will make sure that after the user presses the preview, the scale of the preview node will remain the same between the click and the start of the transition. This is important because since we are now altering the preview node scale, we need the post node to match the same scale when the entering transition is triggered.

After 200ms, this.onClick resets the state, but this will happen well after our transition has started so this shouldn’t cause any problem!

The reason for onMouseUp and the transitioning flag is to make this work on non 3D touch devices. It’s sort of hacky and might be improved. Please let me know if you have suggestions.

Managing the scale at the start of the entering transition

Now that we’ve used PressureJS, it is likely that the scale of the preview node at the beginning of the entering transition is not equal to 1.

In Post.js, head over to getPreviewStyleAndPosition and update it like below:

Since getBoundingClientRect returns coordinates that already take into account the node transform, getPreviewStyleAndPosition needs to compensate for the scale and revert width, height and top to their true value before the transform.

One very very last thing: in onEnter, since we now animate the scale, make sure this.to also has an attribute of scale: 1.

Run the app again, and we have a working transition with PressureJS ;)

Close icon regression

Unfortunately, animating the scale in executeEnteringTransition creates a bug: the close icon scrolls with the page, although its position is supposed to be fixed. The reason is found in this StackOverflow issue. In fact, when the entering transition is over, the post node is left with style attributes, one of them being transform: scale(1), which messes with the close icon fixed position. As a fix, let’s remove all style attributes from the post node once the entering transition is fully completed, just before the done() statement in executeEnteringTransition:

Fixes the close icon position issue.

Last details

The entering transition

If you look closely at the entering transition from the App Store app, you’ll notice that the width of the post animates differently than the top and height.

We’ll use the composite and chain functions of Popmotion to make our entering transition look even better.

In executeEnteringTransition, just after hiding the preview node, replace the tween function with the following code:

The first part of the code decomposes this.from and this.to so that height, top and scale can be animated separately. Then composite composes two tween animations:

  1. The first tween animates the height, top and scale.
  2. The second tween is delayed for 500ms and animates the rest of the properties (only width and borderRadius in reality).

The result of composite is two objects that are merged in the pipe function before passing them to start.

Changing the contrast of the close icon

The close icon in the App Store changes when scrolling past the post image. This one is really simple to implement although a bit verbose. I’ll just list the modifications without much explanations since they are rather straightforward.

Let’s create the .invert class first in Post.scss:

Right after Post.js imports, add the constant below:

const CLOSE_INVERTED_THRESHOLD = 0.64;

Then find onScroll and add the following lines at the end of the method:

An state attribute closeInverted is also added.

Last thing in the render function, we need the .invert class to be added when this.state.closeInverted is true.

And there you go, our prototype is FINALLY finished, and this is more or less the code you should have at this point.

I know this was very long, hope you managed to read until here though!

--

--