Animating Across Routes with Ember Animated

Watch Sam add a staggered, across-route transition to EmberMap's frontend codebase!

Summary


Across-route animations in Ember Animated rely on two special categories of sprites: sentSprites and receivedSprites. Let's use them to add some animation to EmberMap's podcast page.

We want to animate a poster from the index page to its final position and size on the detail page. Let's start by wrapping each card on the index page in an animated-value:

  {{#each sortedEpisodes as |episode|}}
    <grid.Column>
+     {{#animated-value episode use=this.transition}}
        <Podcast::Components::PodcastCard @episode={{episode}} />
+     {{/animated-value}}
    </grid.Column>
  {{/each}}

Similarly, we'll wrap the final image on the detail page in an animated-value, since that's the target of our animating poster. The large image in the video hero lives in the ui-video-hero component, right where we render the video-player:

+ {{#animated-value video use=this.transition}}
    {{video-player
      sources=video.encodes
      video=video
      poster=(img-url video.posterS3Key w=1280)
      data-test-id="video-player"
      playbackRate=state.playbackRate
      did-add-video-element=(action 'setVideoElement')
      on-end=(action 'onEnd')
      on-play=(action 'onPlay')
      on-pause=(action 'onPause')
      on-percent-complete=(action 'onPercentComplete')}}
+ {{/animated-value}}

We'll also define transitions for each one of these animators, and just log out the TransitionContext argument for now:

// pods/podcast/index/controller.js
*transition() {
  console.log('index: ', arguments[0]);
}
// pods/components/ui-video-hero/component.js
*transition() {
  console.log('detail: ', arguments[0]);
}

We can now see what sprites and transitions we're working with when we navigate from the index page to a particular episode.

When we click an episode, we see that the detail transition is firing, with one element in the receivedSprites category. That tells us that Ember Animated has matched the predicate to both our new animators across routes. If we hit the back button, we'll see the index transition fire with one received sprite as well.

Note that we don't see the sentSprites side of the match firing as we navigate, since neither of our animators is using the finalRemoval option. But for this video, the receiving side will be enough.

Let's try animating the received sprite on the detail side. We want to move and resize the image from its starting point in the grid, to its final location in the video hero:

import resize from "ember-animated/motions/resize";
import move from "ember-animated/motions/move";

export default Component.extend({
  *transition({ receivedSprites }) {
    receivedSprites.forEach(sprite => {
      resize(sprite, { easing: easeExpOut, duration: duration * 0.5 });
      move(sprite, { easing: easeExpOut, duration: duration * 0.5 });
    });
  }
})

We've got movement! But the page looks pretty broken. Let's start to clean it up.

The first thing we notice is that the text on the episode detail page renders right at the top. That tells us we forgot to add an AnimatedContainer to keep place in the DOM for the moving poster image. Let's add that in to our ui-video-hero's template:

+ <AnimatedContainer>
    {{#animated-value video use=this.transition}}
      {{video-player
        sources=video.encodes
        video=video
        poster=(img-url video.posterS3Key w=1280)
        data-test-id="video-player"
        playbackRate=state.playbackRate
        did-add-video-element=(action 'setVideoElement')
        on-end=(action 'onEnd')
        on-play=(action 'onPlay')
        on-pause=(action 'onPause')
        on-percent-complete=(action 'onPercentComplete')}}
    {{/animated-value}}
+ </AnimatedContainer>

That will hold room for our image, and the text is no longer rearranging as much.

Next, if we slow down the animation and look at our moving image's initial state, it seems like the sizing is off. That's because it's using the entire PodcastCard component as its initial bounds, but the card includes both the poster image and the title and description block. We really only want to use the image as the poster's initial bounds.

Right now, our animator looks like this:

{{#animated-value episode use=this.transition}}
  <Podcast::Components::PodcastCard @episode={{episode}} />
{{/animated-value}}

It would be ideal if we could wrap only the image. Maybe we could decompose our PodcastCard so we can do just that:

<Podcast::Components::PodcastCard @episode={{episode}} as |card|>
  {{#animated-value episode use=this.transition}}
    <card.Image />
  {{/animated-value}}
  <card.Body />
</Podcast::Components::PodcastCard>

Let's implement this deconstructed API with contextual components!

In our podcast-card template, the image and body are wrapped in these elements:

{{! image}}
{{#aspect-ratio ratio='16:9'}}
  ...rest of image
{{/aspect-ratio}}

{{! body}}
<div class='bg-white p-3 xs:px-20px'>
  ...rest of body
</div>

Let's extract them to nested components and render them in place.

{{! image}}
<Podcast::Components::PodcastCard::Image @episode={{episode}} />

{{! body}}
<Podcast::Components::PodcastCard::Body @episode={{episode}} />

Now, if the caller is passing in a block, we can yield these out:

{{#if (has-block)}}
  {{yield (hash
    Image=(component 'podcast/components/podcast-card/image' episode=episode)
    Body=(component 'podcast/components/podcast-card/body' episode=episode)
  )}}
{{else}}
  <Podcast::Components::PodcastCard::Image @episode={{episode}} />
  <Podcast::Components::PodcastCard::Body @episode={{episode}} />
{{/if}}

And if we check our animation, the poster now takes only the size of the image from the grid! Not to mention, our PodcastCard has become a bit more flexible, should new use cases arise in the future. Winning.

Next, let's work on fading in the elements on the episode detail page, so they don't just pop onto the screen while our poster's moving.

We can wrap our entire title, summary and description block in a new animator:

+ {{animated-value model use=this.fade}}
    {{#ui-container}}
      {{#ui-title}}
        {{model.title}}
      {{/ui-title}}
      <p>
         Episode {{model.position}}
         //
         The EmberMap Podcast
      </p>

      {{! rest of body }}
    {{/ui-container}}
+ {{/animated-value}}

If we define a new fade transition on the component and log its context, we'll see it's being triggered with receivedSprites, just like our other animator. So let's fade them in.

import { fadeIn } from "ember-animated/motions/opacity";

*fade({ receivedSprites }) {
  receivedSprites.forEach(sprite => {
    fadeIn(sprite);
  });
}

Hmm... not working.

The problem here is receivedSprites default to full opacity, since they tend to have a representation within the animator of the matching predicate (in this case, the podcast card from the index grid). But in this case, we do want to fade this content in from 0 opacity. We can do that by using applyStyles and telling our fade motion to start from 0 to 100.

*fade({ receivedSprites }) {
  receivedSprites.forEach(sprite => {
+   sprite.applyStyles({
+     opacity: 0
+   });
-   fadeIn(sprite);
+   fadeIn(sprite, { from: 0, to: 100 });
  });
}

Progress! We're seeing some fading behavior. But our content is mispositioned.

Let's start by wrapping it in an AnimatedContainer, since it does affect the flow of the rest of the document.

+ <AnimatedContainer>
    {{animated-value model use=this.fade}}
      {{#ui-container}}
        {{#ui-title}}
          {{model.title}}
        {{/ui-title}}
        <p>
           Episode {{model.position}}
           //
           The EmberMap Podcast
        </p>

        {{! rest of body }}
      {{/ui-container}}
    {{/animated-value}}
+ </AnimatedContainer>

Closer... but our content seems off to the right. What gives?

Because we're working with receivedSprites, Ember Animated is going to start these off at the initial bounds. In this case, that's where the PodcastCard was being rendered on the index grid. But in our case, we just want our content to fade in from its final location. It's not moving from anywhere else.

So, let's tell our received sprites in this fade transition to move to their final position, and then fade in:

*fade({ receivedSprites }) {
  receivedSprites.forEach(sprite => {
    sprite.applyStyles({
      opacity: 0
    });
+   sprite.moveToFinalPosition()

    fadeIn(sprite, { from: 0, to: 100 });
  });
}

Almost there! Our content's in place, and it is fading in... but there's a jump right at the end. Because we have a container in place, the final culprit of this sort of issue is usually margin collapse. And indeed, if we check our template, our content has an my-4 class on it, which is adding in some vertical margin. If we replace it with some padding instead (py-4), the jumping goes away.

Alright - we've got a great fade in transition working on our content! We now want to fade in the rest of the elements on this page: the player controls, and the black video background.

Instead of copying and pasting all the work we just did, why don't we create a reusable component? We'll call it Animate, and it'll contain animation logic specific to the domain of our application.

The template will render an animator, passing in a @model arg and pointing to a fade transition

{{#animated-value @model use=this.fade}}
  {{yield}}
{{/animated-value}}

and the component will contain the definition for fade:

import Component from "@ember/component";
import { fadeIn } from "ember-animated/motions/opacity";

export default Component.extend({
  *fade({ receivedSprites }) {
    receivedSprites.forEach(sprite => {
      sprite.applyStyles({
        opacity: 0
      });
      sprite.moveToFinalPosition();

      fadeIn(sprite, { from: 0, easing: easeExpOut });
    });
  }
});

Now we can replace our animator with our new component:

  <AnimatedContainer>
-   {{animated-value model use=this.fade}}
+   <Animate @model={{model}}>
      {{#ui-container}}
        {{#ui-title}}
          {{model.title}}
        {{/ui-title}}
        <p>
           Episode {{model.position}}
           //
           The EmberMap Podcast
        </p>

        {{! rest of body }}
      {{/ui-container}}
-   {{/animated-value}}
+   </Animate>
  </AnimatedContainer>

And everything still works!

Let's use our new component to fade in the player controls:

+ <AnimatedContainer>
+   <Animate @model={{video}}>
      {{#if screen.isSmallAndDown}}
        {{ui-video-hero/control-bar-collapsible clip=video}}
      {{else}}
        {{ui-video-hero/control-bar clip=video}}
      {{/if}}
+   </Animate>
+ </AnimatedContainer>

Note we also add a container here, to hold room in the DOM for the control bar.

As more of these elements are fading in, we can see some overlap happening with our moving poster & the other elements. We want the poster to always be on top while it's moving, so let's apply a temporary z-index in its transition function:

  *transition({ receivedSprites }) {
    receivedSprites.forEach(sprite => {
+     sprite.applyStyles({
+       zIndex: 1
+     });

      resize(sprite);
      move(sprite);
    });
  }

Next, we need to fade in our black background.

Right now, the black background comes from the bg-true-black class on a parent div in our ui-video-hero component. If we try to wrap this div in an animator, we'll run into trouble, because we've already wrapped a child – the video-player in an animated-value of its own. In general, it's best to use sibling animators rather than nested ones, to get the most predictable results in Ember Animated.

So let's refactor the background so that we can wrap it in an animator on its own.

- <div class="bg-true-black">
+ <div class="relative">
+   <Animate @model={{video}}>
+     <div class='absolute pin bg-true-black'></div>
+   </Animate>

    ... rest of template

Just a bit of CSS, and we still have our static black background, now with animation!

The last element we need to fade is the play button.

+ <Animate @model={{video}}>
    <div class="video-player__play-button">
      {{fa-icon 'play' prefix="fas"}}
    </div>
+ </Animate>

And with that, all the elements on the new page are fading!

Now, everything happening at once is kind of stark, so we can further improve this animation by staggering the animations. First, we'll move the poster, and then we'll fade the new elements in. We can do that using the duration argument.

  *transition({ duration, receivedSprites }) {
    receivedSprites.forEach(sprite => {
      sprite.applyStyles({
        zIndex: 1
      });

-     resize(sprite);
+     resize(sprite, { duration: duration * 0.5 });
-     move(sprite);
+     move(sprite, { duration: duration * 0.5 });
    });
  }

This makes the poster move in half the time of the animation's full duration.

Now, let's update our Animate component. We want it to wait that first half, while the poster is moving, and then fade everything in using the second half of the duration. We can do that using the wait helper.

import { wait } from "ember-animated";

*fade({ duration, receivedSprites }) {
  receivedSprites.forEach(sprite => {
    sprite.applyStyles({
      opacity: 0
    });
    sprite.moveToFinalPosition();
  });

  yield wait(duration * 0.5);

  receivedSprites.forEach(sprite => {
    fadeIn(sprite, { from: 0, to: 100 } });
  });
}

Note that we hide & move the sprites to their final positions, then wait, then fade them in.

Looking much better.

Finally, let's add a bit of life to our motions by using a custom easing. We'll use exponential-out easing from the d3-ease package, and we'll apply to both our main transition as well as our fade:

import { easeExpOut } from "d3-ease";

*transition({ duration, receivedSprites }) {
  receivedSprites.forEach(sprite => {
    sprite.applyStyles({
      zIndex: 1
    });

    resize(sprite, { easing: easeExpOut, duration: duration * 0.5 });
    move(sprite, { easing: easeExpOut, duration: duration * 0.5 });
  });
},

*fade({ duration, receivedSprites }) {
  receivedSprites.forEach(sprite => {
    sprite.applyStyles({
      opacity: 0
    });
    sprite.moveToFinalPosition();
  });

  yield wait(duration * 0.5);

  receivedSprites.forEach(sprite => {
    fadeIn(sprite, { from: 0, easing: easeExpOut });
  });
}

These easings have such a dramatic effect on the look and feel of our animations, so make sure to experiment with them.

And that's it! With under 50 lines of animation code, and without changing any of the data loading or business logic in our application, we've added some nice across-route transitions that bring a bit of life to our Ember app.

πŸ‘‹ Hey there, Ember dev!

We hope you enjoyed this free video πŸ™‚

If you like it and want to keep learning with us, we've written a free 6-lesson email course about the fundamental patterns of modern component design in Ember.

To get the first lesson now, enter your best email address below:

You can also check out more details about the course by clicking here.

Questions?

Send us a tweet:

Or ask us in Inside EmberMap, our private Slack workspace for subscribers.