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.
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.
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.
- The user drags the
Post
down. - The
Post
scales down up to a limit. - 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.
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!
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:
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:
- The scale of the post node changes when dragged. Therefore we need to animate it back to
1
. - 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:
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 byontouchstart
andontouchend
events.isDragging
is a flag set totrue
whenever the user touches the screen and a negative scroll is registered.- Only when
isDragging
is set totrue
willdragProgress
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.
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:
- The user presses the preview node.
- The state
force
updates and the node is scaled accordingly. - When the user lifts the finger, the
end
callback fromPressure
is fired and sets a timeout of50ms
before resetting theforce
state to0
. - If the gesture is recognized as a click,
onClick
fires just after theend
callback has been triggered, so it will cancel resetting theforce
.
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
:
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:
- The first
tween
animates theheight
,top
andscale
. - The second tween is delayed for
500ms
and animates the rest of the properties (onlywidth
andborderRadius
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:
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!