Polymer pattern: Sliding page transitions

How to animate transitioning between pages like native mobile apps. Connect to <app-route>. 60fps. Code available.

Ronny Roeller
NEXT Engineering
5 min readFeb 8, 2017

--

Progressive Web Apps aim to provide a native mobile experience. In contrast to web applications, native apps animate transitions between pages: the old page typically slides out to the left and the new page slides in from the right.

Polymer comes with all the pieces to build such page transitions: sliding animations, app routing, and everything to glue these things together. Yet, building page transitions somehow isn’t as trivial as it sounds...

Here is our approach!

Challenges

Let’s first understand what makes page animations complicated:

Challenge #1: Play animation *during* the page transition

Normally, web applications flip between pages: the app is on the first page (URL), the user clicks a link, the URL changes, and the page is directly on the second page. In contrast, during an animated transition we need to be on both pages at the same time: the left half of the screen shows the old page whereas the right half shows already the new page.

Challenge #2: Play different animation for back transition

We want to slide in the other direction if the user tapped the back button: the right side shows now the old page whereas the left side shows the new page. This means we not only need to be on two pages at the same time but we also need to know if we are in forward or backward mode.

Challenge #3: Keep transitions smooth

Assuming, we can somehow transition between two pages. Typically, changing the URL to the second page will trigger the data loading. But doing loading/processing in parallel is a sure way of introducing jank in our animation. Good bye 60fps!

We therefore need to delay all the heavy lifting until the transition animation is completed.

Putting the basic in place

As said, we need to be on the old and on the new page during the transition. We could do something uber-funky with the URL to reflect this state but it’s going to get very complex and likely screws up our browser history. So, that’s not the way to go.

We need to signalize in a different way that the user wants to change the page. What we will do: the element triggering the transition will fire a request-change-page event. Whenever the app element receives this element, it will play the animation, and adjust the app & browser location accordingly.

Hint: The following code snippets are simplified parts from the complete example on Github.

The following example forwards the user to the detail page of item 1 (for a backward animation the animation property could be adjusted accordingly):

onTapItem1: function() {
this.fire('request-change-page', {
path: 'detail/1',
animation: 'forward'
});
}

Now, the app element listens for this event, sets the entry/exit animations, and finally changes the path:

listeners: {
'request-change-page': 'onRequestChangePage',
},
onRequestChangePage: function(e) {
// Set entry and exit animation for <neon-animated-pages>
if (e.detail.animation === 'forward') {
this._entryAnimation = 'slide-from-right-animation';
this._exitAnimation = 'slide-left-animation';
}
this._path = e.detail.path;
},

Next, Polymer’s <app-route-converter> creates a route based on the path, from which <app-route> can extract the data:

<app-route-converter
path="{{_path}}"
route="{{_route}}"
>
</app-route-converter>
<app-route
route="[[_route]]"
pattern=":page"
data="{{_data}}"
tail="{{_tail}}"
>
</app-route>
<app-location
route="{{_route}}"
use-hash-as-path>
</app-location>

Passing the properties to <neon-animated-pages> is all that’s left to do:

<neon-animated-pages
selected="{{_data.page}}"
entry-animation="[[_entryAnimation]]"
exit-animation="[[_exitAnimation]]"
>
<my-lister id="lister"></my-lister>
<my-detail id="detail" route="[[_tail]]"></my-detail>
</neon-animated-pages>

As you can see, we pass the tail of the route to the child element for further processing. The detail page will extract with it the ID (e.g. from path detail/1).

With all these pieces in place, our first two challenge are resolved: The page and the browser location adjusts whenever a request-change-page event arrives. Equally, the state adjust whenever the browser location changes (e.g. deeplink). And whenever a transition occurs, the matching animation is played.

Be nice: prepare for transitions

Let’s look to the remaining challenge: make the animations smooth.

Right now, we tell the second page that it will be the new page when the transition starts. But to prevent jank, we don’t want the second page to update it’s data before the animation starts.

The obvious solution is to inform the second page about the change only once the animation completed. But this introduces a new issue: the second page might still contain data from an old state. If we go e.g. from detail/1 to lister and then to detail/2: the detail page would contain during the second transition still data from item 1, which would then suddenly change when the data of item 2 is loaded.

To overcome this, we’ll 1) inform the second page that it should prepare for a transition, once that’s done 2) start the animation, and 3) inform the second page that the data can be loaded:

<neon-animated-pages
selected="{{_page}}"
entry-animation="[[_entryAnimation]]"
exit-animation="[[_exitAnimation]]"
on-neon-animation-finish="_onPageAnimationFinish">
<my-detail id="detail" route="[[_pageTail]]"></my-detail>
...onPageChanged: function(_page) {
// Wait until <app-route> set "page" and "_tail"
this.async(() => {
const newPageEl = this.$[_page];
if (newPageEl && newPageEl.prepareTransition) {
// Pass future state to the target page
newPageEl.prepareTransition(this._tail);
}

if (!this._entryAnimation || !this._exitAnimation) {
// Explicitely call handling of post-animation if there is no animation
this.onPageAnimationFinish();
}
});
},
onPageAnimationFinish: function() {
// Only pass data to the detail page once the animation is done
this._pageTail = this._tail;
},

Hint: The snippet starts the animation before the second page finalized its preparation. Check the Github example on how to avoid jank by using Promises instead.

Once informed about the upcoming transition, the detail page can simply reflect an inTransition property to the DOM and then hide/show a placeholder instead of the real content:

<style>
:host[in-transition] .content,.placeholder {
display: none;
}
:host[in-transition] .placeholder {
display: block;
}
</style>
...properties: {
inTransition: {
type: Boolean,
value: false,
reflectToAttribute: true,
},
},

When setting the transition state, it makes sense to check if the content is really expected to be dirty.

<app-route
pattern="/:id"
route="[[route]]"
data="{{data}}">
</app-route>
<app-route
pattern="/:id"
route="[[prepareRoute]]"
data="{{prepareData}}">
</app-route>
...prepareTransition: function(route) {
this.prepareRoute = route;
// Wait until route is decomposed
this.async(() => {
if (this.data.id !== this.prepareData.id) {
// Set in transition mode if the ID is going to change
this.inTransition = true;
}
});
},
onIdChanged: function(id) {
this.inTransition = false;

// Load data
},

Now, the detail page activates the transition state only when we look to a different item (ID changes). For the same item, we keep showing the old content, assuming it will change only marginally once the data gets refreshed.

Check out the full code example

The above snippets highlight the key aspects of the pattern. See for a complete code example: https://github.com/Collaborne/polymer-pattern-sliding-pages

And of course: I’d love to hear if anybody has improvement ideas or a completely different pattern for sliding pages with Polymer!

Happy coding!

Want to learn more about Polymer? Have a look to all our Medium posts on Polymer.

--

--

Ronny Roeller
NEXT Engineering

CTO at nextapp.co # Product discovery platform for high performing teams that bring their customers into every decision