Advanced route transitions with React Transition Group and Popmotion [Part 2]
This is the second part of the tutorial. It will focus on the transition animation.
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 1 of this tutorial, we’ve set up the basics of the app, the redux store and the router are now functional. In the second part, we will actually animate the transition from our List
page, to our Post
page.
The entering animation
Now comes the fun part. The good news is that most of the work is going to be focused on the Post
component, so we’re mostly going to touch Post.js
and Post.scss
files only.
First, let’s add the Popmotion dependency.
yarn add popmotion
From style and to style
I’ll be calling from style the position, size and style of our article when clicked in the List page, and to style its position, size and style when the animation is finished and the article is ready to be scrolled in the Post page. Our objective will be to animate from the from style to the to style.
To make sure you can fully see what’s happening during the transition, add the following style attribute to the TransitionGroup
component in App.js
:
<TransitionGroup style={{opacity: 0.5}}>
Now open the Post.js
file and update it accordingly:
So here is what the code does:
- First, we add imports from
popmotion
and its related packagestylefire
. We’re not using them all yet, their time will come. - We’re adding
from
andto
instance properties to thePost
class. As you might guess,this.from
andthis.to
will contain the from style and to style respectively. - In the
render
method, we’re now referencing the DOM element containing the post itself inthis.post
. - The
componentDidMount
method creates local propertiesthis.postStyler
andthis.postScroll
that are convenient setters and getters for style and scroll attributes ofthis.post
. onEnter
prop has been added toCSSTransition
and callsthis.onEnter
which is empty for now.onEnter
is called right after the entering transition starts.addEndListener
prop has also been added toCSSTransition
, and is callingthis.onAddEndListener
method. As per the documentation,addEndListener
is called with the transitioning DOM node and a done callback. Allows for more fine grained transition end logic.- At the moment,
onAddEndListener
only calls thedone
function passed as a parameter to signal the end of the transition. - We’re adding instance method
getPreviewStyleAndPosition
that we will implement in a moment. - You might notice that we’ve also removed the
timeout
prop fromCSSTransition
. In fact, timeouts are no longer necessary when usingaddEventListener
as a prop and thedone
callback.
If you run the app at this point, you should notice that the transition between the post route and the home route is executed instantly with no timeout. This is because CSSTransition
relies on the done
callback, which is called immediately.
The from style
The from style of our Post
needs to perfectly match the Preview
. Therefore, we need to access the DOM element of the Preview
component to calculate its size, position and style. We can reference the preview node with the following code:
const { post } = this.props.route.data;this.preview = document.querySelector(`.preview[data-id="${post.id}"]`);
My natural instinct was to put this into componentDidMount
, but we need to calculate the from style as soon as possible when the transition starts, in other words within the onEnter
method. And onEnter
is called even before componentDidMount
. So we’ll reference the preview node in onEnter
as below:
We’re also referencing the List
page node (we will need this for handling scroll positions later on). And finally, we’re setting this.from
to the result of getPreviewStyleAndPosition
which we will deal with now.
Calculating the preview node position and style
We now need to fill in the getPreviewStyleAndPosition
method so that it returns the preview node style and position.
As you might expect, we will be using getBoundingClientRect()
to calculate the bounds of this.preview
.
In Post.js
, fill the getTransition
method with the following code:
In the onAddEndListener
method, add the following line and comment out the done
statement (this will prevent the transition from completing and help us work on the from style as the post page will remain mounted undefinitely):
this.postStyler.set(this.from);
// done();
The line above sets the post node style to the style of the preview node returned by this.from
.
If you run the app now, scroll the List
page and transition from to the Post
page, then you’ll immediately notice that our post node and the preview node don’t match.
The explanation is simple: from our CSS class .post
, the post node gets an absolute
positioning.
However, getBoundingClientRect()
returns coordinates relative to the viewport while a DOM element with an absolute
positioning is relative to the parent element, which might have been scrolled. Good news, a fixed
positioning is relative to the viewport and independent from its parent.
Try changing the post node position to fixed
in the inspector and you should see that the post node matches the position of the preview node.
However, the content is still overflowing from the post node. Again, easy fix, add overflow: hidden
to the style of the post node, and everything should now be aligned perfectly.
Great, we’re getting somewhere: what we want to do is fixing the position and hiding the overflow to the post node every time a transition is active (we’re dealing with the entering transition right now, but the reasoning stands for the exiting transition). Fortunately for us, we can do this in CSS directly, since the CSSTransition
group automatically adds .post-enter
classes for us to the Post
page. So just add the following rules to the Post.scss
file:
Run the app again, and you can see that our post node is now perfectly aligned with the preview node.
Ok, we now need to set the to style that matches our destination position and run the animation.
Calculating the destination style and position
Our destination is the expanded post page. Calculating its position is rather straightforward: it’s aligned to the top of the window, should have the same height as the viewport, the same width as the body and sharp corners.
In onEnter
, set this.to
as below:
In onAddEndListener
, add the following lines:
The interesting part happens with the tween
statement (read the Popmotion documentation for a more thorough explanation). In a few words, tween
takes two main parameters (from
and to
) and returns an action you can start. This action runs an update
function that is called every time the tween
updates, and a complete
callback that is called once the tween
reaches the to
value.
What we’re doing here is updating the style of the post node in the update
method, and once it completes, done
is called to let TransitionGroup
know that the transition is completed.
If you run the code at this point, you should see the post node animate smoothly until it reaches its final state… but after the done
callback is triggered, the scroll position messes with us again.
Fixing the scroll
What happens here is pretty simple: once the .post-enter
class is removed from the Post
page, the post node retrieves its absolute positioning and follows the scroll position of the viewport (which was set when scrolling the List
page).
Therefore, once the transition is completed, we just need to set the scroll position of the viewport to the top (i.e. 0
) and our post node should stay to the top!
Popmotion stylefire
package provides a scroll
renderer that comes handy in this situation. In Post.js
, even before the Post
class is declared, add the line below:
const windowScroll = scroll();
As you can see from the docs, when initiating scroll
with no arguments, you get a viewport position renderer, which means you can get and set the window scroll position easily.
In the complete
function from our tween
, just before the done()
statement, add the following line:
windowScroll.set('top', 0);
Now run the app again.
You should get something similar to the capture above. This time, the post element is positioned correctly and everything would look fine if we hadn’t set the opacity of the TransitionGroup
to 0.5
. But we can observe that the List
page underneath jumps back to the top.
You might think no big deal since we can’t see it underneath. But actually when we will close the post page and trigger the exiting transition, we want the List
page to be positioned just like it was before the entering transition.
We will use the same trick of the fixed
position that we’ve seen before. On the List
page, try scrolling the viewport and then styling the .page-list
node with a fixed
position from the Inspector. You should see the List
page jumping back to the top, and that’s expected because fixed
elements ignore the viewport scrolling position.
So position: fixed
is not enough, we also need to set the top
attribute of the List
page style to the scroll position of the viewport. In other words:
const scrollTop = windowScroll.get('top');
document.querySelector('.page-list').style.position = 'fixed';
document.querySelector('.page-list').style.top = -scrollTop;
As you might recall, we’ve set up a style helper for the page list node. So the block above translates into:
const scrollTop = windowScroll.get('top');
this.pageListStyler.set({ position: 'fixed', top: -scrollTop });
Add the two lines above right at the end of onEnter
, and while we’re at it, you can even move the windowScroll.set('top', 0)
out of the complete
block in onAddEndListener
into onEnter
.
Remove the style on the TransitionGroup
component in App.js
, and back to the browser again, and… yes! The entering transition works as expected.
Testing on an iPhone
You thought this was over… Well unfortunately, things don’t work as smoothly on mobile Safari (at least on my iPhone X):
First, there’s a visible flash before the transition starts. Then, once the transition completes, there’s a weird scroll glitch, and the top of the Post
page seems to be under the navigation bar.
Fixing the flash
The flash happens because the image of the Post
hasn’t loaded and isn’t rendered when the Post
mounts, although in our situation the image the same as the one in the List
preview.
That might be a problem with the way Safari caches images. Anyway, no big deal since in a real-world situation, the Post
might use a larger version of the List
thumbnail image so we would have to wait for the image to load.
So let’s wait for the image to load before triggering the animation.
You will need to move some of the current code of onAddEndListener
into to proxy method, let’s call it executeEnteringTransition
. In onAddEndListener
, we will add the image onload
listener as so:
Right before we execute the transition, we’re creating an Image object that shares the same source as the Post
thumbnail.
If the image is not loaded (i.e. img.complete === false
), we wait for it to load and then we execute the transition. If the image is completed already, then we execute the transition immediately.
Let’s run the app again from an iPhone.
Well again, we have a flash. An even bigger one (I won’t make the capture this time). The reason why we see that flash is because while the image loads, the post component still mounts and shows before we’ve set the post node style to the from style.
The fix is simple. We need the post node visibility to be hidden
when it mounts, and only once the image has loaded, set its visibility to visible
.
So, in Post.scss
, add the following class:
.page-post.post-enter .post {
visibility: hidden;
}
In Post.js
, we’re going to have to set the post node visibility to visible manually as so in executeEnteringTransition
:
this.postStyler.set({ ...this.from, visibility: 'visible' });
And this time, the appearing glitch has gone away!
Fixing the scroll issue
Unfortunately, I’m not exactly sure what happens with the scroll glitch. I guess that preventing elements from scrolling makes the nav and tab bars come into the viewport, which messes with the top
style attribute when the animation runs.
I’m not super proud of the fix, but the idea is that once our tween
is completed, we animate the scroll of the viewport to 0
. So, replace the complete
statement of our tween
with the following:
Again, we tween the value of the viewport scroll to zero, and once that tween is completed, we can call the done
callback.
Ok now we can safely move on to the exiting transition.
Here is the code up to this point in the part-2-entering-transition
branch, and let’s take a break before moving onto the exiting animation.
The exiting animation
First, we need to detect if we’re entering or exiting within the onAddEndListener
method. Fortunately, TransitionGroup
passes a in
prop to its children that defines whether they should be displayed or not.
When the <Post/>
component is entering, its in
prop is true
, false
when it is exiting. So our logic in onAddEndListener
will be different depending on this prop.
Create a new executeExitingTransition
with the same arguments as executeEnteringTransition
and with done()
as the only statement.
Following the same logic as onEnter
, add the onExit
prop to CSSTransition
and make it call an empty onExit
method that you should create.
Change the onAddEndListener
method so that it calls executeEnteringTransition
or executeExitingTransition
depending on the in
prop:
Our work at this point will be to implement onExit
and executeExitingTransition
methods.
onEnter
sets this.from
to the preview node style and this.to
to the post node style. For the exiting transition, onExit
should do the exact opposite.
Now, let’s get to the executeExitingTransition
and add the following code:
This time, instead of a tween
, we will be using a spring
. You can check the Popmotion documentation to explore the different parameters, but it works more or less the same as a tween
. In the complete
method, we free the List
page and set the viewport scroll to what it was before the entering transition.
Before moving on, modify your Post.scss
file to also set the .post
position and overflow to fixed
and hidden
when the exiting transition is running, which happens when the .post-exit
class is added to our post page node.
.page-post {
&.post-enter,
&.post-exit { // add just this line here
.post {
position: fixed;
overflow: hidden;
pointer-events: none;
}
}
}
Hiding the preview node
If you test the app now, you’ll see that it works pretty well for a first try. However, if you remove style={{ opacity: 0.5 }}
prop from TransitionGroup
in App.js
, you will see that when bouncing back in, the preview node can clearly be seen underneath.
Well this is pretty easy to fix: we need to hide the preview node when starting the entering transition, and show it once the exiting transition is completed.
In executeEnteringTransition
, as the second statement, type:
this.preview.style.visibility = 'hidden';
And then, at the start of the complete
block of executeExitingTransition
:
this.preview.style.visibility = 'visible';
This should be better now.
Handling the Post scroll
We need to check what happens when scrolling the Post
content and then closing it.
Click on the second preview post — it has the longest content, scroll through it, and click the close button. This shouldn’t look too bad, and that might be sufficient for some. But let me use a slower animation and let’s see what’s really happening here (replace the spring
with a tween
and a 10000
duration).
As you can see, as soon as we clicked the close button, the Post
looks like it’s scrolling to top instantly. This is again because of the fixed
position we’re applying to the post node when transitioning. So before the transition even starts, we need to make sure we’ve set the post node to the correct scroll position.
We’ll be using a different technique than for the List
page and use a helper class called .scroll-block
:
Here is what we will be doing to clip the post node to the correct scroll position:
const scrollTop = windowScroll.get('top');
this.post.classList.add('scroll-block');
this.postScroll.set('top', scrollTop);
First line get’s the viewport scroll position. Then we add the .scroll-block
class to the post node. And finally, we set the post scroll position to the previous viewport scroll position.
Remember, we want to add this bit of code, as soon as the exiting transition starts. onExit
seems like the perfect place for this.
Add the block of code above at the end of the onExit
method:
Run the app, and you’ll see that nothing has changed.
Here’s what’s happening:
- The transition starts.
.post-exit
class is added to the post page, and our post node gets fixed position and hidden overflow.onEnter
is fired and we calculate the viewport scroll and set the.scroll-block
class to the post node.
The problem is that on step 3, the viewport scroll is equal to zero. In fact, as soon as the our post node gets a fixed position, the viewport doesn’t have any content to scroll, and therefore its position is reset to 0
. The fix is simple. We don’t want the post node to get a fixed
position when the .post-exit
class is added by CSSTransition
.
Remove the &.post-exit
rule in Post.scss
, run the app, and do the same test as before: you should notice that the Post starts from the right position, but it doesn’t scroll up.
Again, let’s fix this. What we want to do is also animate this.postScroll
during the exiting transition so that it transitions back to 0
.
Change onExit
so that this.from
and this.to
also include scrollTop
values to interpolate from.
Then, modify executeExitingTransition
with the below:
As you can see, we’re decomposing the update function to animate this.postStyler
and this.postScroll
separately.
Run the app again and…
Change the tween
back to a spring
, and you should have a nice and cool bouncing back animation :)
One last thing: easing on entering and fixing one last flash
Before moving to Part 3, let’s make the entering animation pop a bit more. Add the following line to the top of the Post.js
file, right after the import
statements.
const myEasing = cubicBezier(0.8, -0.25, 0.33, 1.52);
Then in executeEnteringAnimation
, change the duration
of the tween
to 800
, and add ease: myEasing
as an additional parameter (just under duration)
. Run the entering transition and you should have a subtle back in back out effect.
You might also observe a very quick white flash on your iPhone, during the entering transition. After quite a lot of digging, I’ve found out that this happens right at the beginning of executingEnteringTransition
, when setting the post node visibility to visible
, and the preview node visibility to hidden
. Since Chrome handles it well, I’m going to do something I hate, a conditional block for Safari.
Add the bowser
dependency:
yarn add bowser
Then tweak Post.js
with the following code:
Code at this point is available on the part-2-complete
branch.
What’s next?
We’ve done the basics, but we still have job to do. Part 3 will cover:
- Implementing the drag to close functionality on iOS
- Playing with Pressure.js
- A few more little details