Advertisement

#6 Animating the Navigation Between Views

In this article we will convert from specifying our animation css class explicitly to creating them based on a convention. This change will make it much easier to add new animations. Following that change we will create the ability for us to animation elements in and out in the x direction. We use this along with the notion of a parent for our routes to conditionally decide which direction our views should be animated in and out when a user requests a route change.

It was fun while it lasted

  • root
    • src
      • components
        • animations
          • _animation-classes.scss

Originally we decided to use a system where we would explicitly specify all of the animation classes in our sass file and then export them for use in our script. This would be fine in principle but it has not taken long for me to decide it is more trouble than it is worth. Instead of specifying them individually we are going to take a more convention based approach. We are going to start by converting what did previously starting with our definitions of the css classes (a).

_animation-classes.scss

@function active($name) {
    @return $name + -active;
}

@function after($name) {
    @return $name + -after;
}

@function before($name) {
    @return $name + -before;
}

$fadeIn : fade-in;
$fadeOut: fade-out;
(a) Converting from specifying all of the animation classes explicitly to using a convention based system.

Applying the convention to the fade animation

  • root
    • src
      • component
        • animations
          • _animation-fade.sass

In accordance with our new approach we need to change how the classes are expressed within our fade animation (b). Notice that we are using the helper functions that we defined previously to help us construct the classes.

_animation-fade.sass

...
@mixin animation-classes-fade-in($duration: 200ms, $easing: ease-in-out)
    &.#{active($fadeIn)}
        @include animation-options($duration, $easing)
        animation-name: fadeIn
    &.#{after($fadeIn)}
        opacity: 1
    &.#{before($fadeIn)}
        opacity: 0

@mixin animation-classes-fade-out($duration: 200ms, $easing: ease-in-out)
    &.#{active($fadeOut)}
        @include animation-options($duration, $easing)
        animation-name: fadeOut
    &.#{after($fadeOut)}
        opacity: 0
    &.#{before($fadeOut)}
        opacity: 1
...
(b) Converting our fade animation class definitions to use our convention based approach.

Applying the convention to the animatable item

  • root
    • src
      • components
        • animations
          • Animatable.vue

The last thing that we need to update before everything works again is our animatable object. There are two changes that we need to make one is to update our map and then to change how we create the string that will become the css classes (c). Once that is done we can also remove the getCssClass helper method as it is no longer needed.

AnimatableItem.vue

<script lang="ts">
...
export default class AnimatableItem extends Vue {
    ...
    private readonly animationMap = new Map<AnimationTypes, string>([
        [AnimationTypes.FadeIn, "fade-in"],
        [AnimationTypes.FadeOut, "fade-out"],
    ]);
    ...
    private animate(options: IAnimateOptions) {
        ...
        this.updateWithPreAndPost(
            AnimationStages.BeforeApplyPre,
            AnimationStages.BeforeApplyPost,
            () => this.beforeCssClass = `${animation}-before`);
        requestAnimationFrame(() => {
            this.updateWithPreAndPost(
                AnimationStages.ActiveApplyPre,
                AnimationStages.ActiveApplyPost,
                () => this.activeCssClass = `${animation}-active`);
            ...
        });
    }

    private animationEnd() {
        ...
        this.updateWithPreAndPost(
            AnimationStages.AfterApplyPre,
            AnimationStages.AfterApplyPost,
            () => this.afterCssClass = `${animation}-after`);
        ...
    }
    ...
    private getCssClass(index: string) { // <-- Remove this method
        const css = animationClasses[index];
        if (typeof(css) === "undefined") {
            // tslint:disable-next-line:no-console
            console.warn(`CSS animation class for ${index} is undefined.`);
            return "";
        }
        return css;
    }
}
</script>
(c) Updating the map and the way our classes are created and lastly removing the getCssClass helper method.

Time for a new animation

  • root
    • src
      • components
        • animations
          • _animation-classes.scss

With all of those changes made we can no proceed to create some new animations. We will start by defining the base of the css classes that we will be using (d).

_animation-classes.scss

...
$translateInFromLeft : translate-in-from-left;
$translateInFromRight: translate-in-from-right;
$translateOutToLeft  : translate-out-to-left;
$translateOutToRight : translate-out-to-right;
(d) Specifying the base of the css classes for our new translate animations.

Need some css for our translate animations to work

  • root
    • src
      • components
        • animations
          • _animation-translate.sass

Here we are following the pattern that we created when we made the fade animations previously. We start by defining the key frames for translating an element in and out from both the left and right sides. After that we say what css properties should be applied to the element when each of the animation classes are applied to it. Of course we also provide an easy way to import all of the key frames and all of the classes at one time (e).

_animation-translate.sass

@import "animation-classes"
@import "animation-options"

@mixin animation-keyframes-translate-in-from-left
    @keyframes translateInFromLeft
        from
            transform: translateX(-100%)
        to
            transform: translateX(0)

@mixin animation-keyframes-translate-in-from-right
    @keyframes translateInFromRight
        from
            transform: translateX(100%)
        to
            transform: translateX(0)

@mixin animation-keyframes-translate-out-to-left
    @keyframes translateOutToLeft
        from 
            transform: translateX(0)
        to
            transform: translateX(-100%)

@mixin animation-keyframes-translate-out-to-right
    @keyframes translateOutToRight
        from 
            transform: translateX(0)
        to
            transform: translateX(100%)

@mixin animation-keyframes-translate
    @include animation-keyframes-translate-in-from-left
    @include animation-keyframes-translate-in-from-right
    @include animation-keyframes-translate-out-to-left
    @include animation-keyframes-translate-out-to-right

@mixin animation-classes-translate-in-from-left($duration: 200ms, $easing: ease-in-out)
    &.#{active($translateInFromLeft)}
        @include animation-options($duration, $easing)
        animation-name: translateInFromLeft
    &.#{after($translateInFromLeft)}
        transform: translateX(0)
    &.#{before($translateInFromLeft)}
        transform: translateX(-100%)

@mixin animation-classes-translate-in-from-right($duration: 200ms, $easing: ease-in-out)
    &.#{active($translateInFromRight)}
        @include animation-options($duration, $easing)
        animation-name: translateInFromRight
    &.#{after($translateInFromRight)}
        transform: translateX(0)
    &.#{before($translateInFromRight)}
        transform: translateX(100%)

@mixin animation-classes-translate-out-to-left($duration: 200ms, $easing: ease-in-out)
    &.#{active($translateOutToLeft)}
        @include animation-options($duration, $easing)
        animation-name: translateOutToLeft
    &.#{after($translateOutToLeft)}
        transform: translateX(-100%)
    &.#{before($translateOutToLeft)}
        transform: translateX(0)

@mixin animation-classes-translate-out-to-right($duration: 200ms, $easing: ease-in-out)
    &.#{active($translateOutToRight)}
        @include animation-options($duration, $easing)
        animation-name: translateOutToRight
    &.#{after($translateOutToRight)}
        transform: translateX(100%)
    &.#{before($translateOutToRight)}
        transform: translateX(0)

@mixin animation-classes-translate($duration: 200ms, $easing: ease-in-out)
    @include animation-classes-translate-in-from-left($duration, $easing)
    @include animation-classes-translate-in-from-right($duration, $easing)
    @include animation-classes-translate-out-to-left($duration, $easing)
    @include animation-classes-translate-out-to-right($duration, $easing)
(e) Defining all of the key frames and css properties that should be applied depending on the animation class.
Advertisement

We want them all

  • root
    • src
      • components
        • animations
          • _animations.sass

Now it is just a small change to our animation file for us to make it easy to import all of the necessary components of our translate animations (f).

_animation.sass

...
@import "animation-translate"

@mixin animation-keyframes
    ...
    @include animation-keyframes-translate;

@mixin animation-classes($selector, $duration: 200ms, $easing: ease-in-out)
    #{$selector}
        ...
        @include animation-classes-translate($duration, $easing)
...
(f) Updating our animation file so that it imports all of the necessary components for our translate animations.

Back to our scripts

  • root
    • src
      • components
        • animations
          • types.ts

In our typescript code we will start by adding four new entries to our animation types enum (g).

types.ts

...
export enum AnimationTypes {
    ...,
    TranslateInFromLeft,
    TranslateInFromRight,
    TranslateOutToLeft,
    TranslateOutToRight,
}
...
(g) Adding to our animation types enum so we can specify which translate animation we want to play.

Last step for our translate animations

  • root
    • src
      • components
        • animations
          • AnimatableItem.vue

The last thing that we have to do before our animations will work is to update our map (h).

AnimatableItem.vue

<script lang="ts">
...
export default class AnimatableItem extends Vue {
    ...
    private readonly animationMap = new Map<AnimationTypes, string>([
        ...,
        [AnimationTypes.TranslateInFromLeft, "translate-in-from-left"],
        [AnimationTypes.TranslateInFromRight, "translate-in-from-right"],
        [AnimationTypes.TranslateOutToLeft, "translate-out-to-left"],
        [AnimationTypes.TranslateOutToRight, "translate-out-to-right"],
    ]);
    ...
}
</script>
(h) Updating our map so that we can bridge between our new enum values and their corresponding base css class names.

Translate instead of fade

  • root
    • src
      • components
        • routing
          • TheRouterOutlet.vue

In order to test that everything is working correctly we will update our router outlet by changing the animation that we want to run from fade to translate out to the left before a route change is requested and to translate in from the right afterwards (i).

TheRouterOutlet.vue

<script lang="ts">
...
export default class TheRouterOutlet extends Vue {
    ...
    private animate(route: Routes) {
        ...
        this.animationSubject.next({ type: AnimationTypes.TranslateOutToLeft });
    }

    private animationComplete() {
        ...
        this.animationSubject.next({ type: AnimationTypes.TranslateInFromRight });
    }
    ...
}
</script>
(i) Changing the animation from fade to translate when a route change is requested.

Adding structure to our routes

  • root
    • src
      • components
        • routing
          • route-entry.ts

Next up is to add the ability to determine the direction our views should animate in and out from/to. In order to do this we will need to add some structure to our routes. For now we will just introduce the concept of a parent for a route (j). With this addition we will create a tree structure for our routes.

route-entry.ts

...
export interface IRouteEntryConfig {
    ...
    parent: RouteEntry | null;
    ...
}

export class RouteEntry {
    ...
    private readonly _parent: RouteEntry | null;
    ...
    constructor(config: IRouteEntryConfig) {
        ...
        this._parent = config.parent;
        ...
    }

    public isChildOf(entry: RouteEntry) {
        return this._parent === entry;
    }
}
(j) Adding a parent field to our route entries will impose a tree structure on our routes.

Updating the routing service

  • root
    • src
      • components
        • routing
          • routing-service.ts

Now we need to add a parent entry to the configuration objects when we create our route entries. As you can see we will be treating the home view as the root of our tree (k).

routing-service.ts

...
const home = new RouteEntry({
    ...,
    parent: null,
    ...,
});

const about = new RouteEntry({
    ...,
    parent: home,
    ...,
});
...
(k) Adding the parent entry for our routes with the home view being the root of our tree.

Pick a direction

  • root
    • src
      • components
        • routing
          • TheRouterOutlet.vue

Now that we have a notion of a parent for a route we can use it to decide which direction we should animate our views in and out from/to (l). For now at least if we animate to a view other than a direct parent of child we will just use the fade animation.

TheRouterOutlet.vue

<script lang="ts">
...
export default class TheRouterOutlet extends Vue {
    ...
    private inAnimation = AnimationTypes.None;
    ...
    private animate(route: Routes) {
        ...
        const from = this.routingService.current;
        
        if (from.isChildOf(this.toEntry)) {
            this.inAnimation = AnimationTypes.TranslateInFromLeft;
            this.animationSubject.next({ type: AnimationTypes.TranslateOutToRight });
        } else if (this.toEntry.isChildOf(from)) {
            this.inAnimation = AnimationTypes.TranslateInFromRight;
            this.animationSubject.next({ type: AnimationTypes.TranslateOutToLeft });
        } else {
            this.inAnimation = AnimationTypes.FadeIn;
            this.animationSubject.next({ type: AnimationTypes.FadeOut });
        }
    }

    private animationComplete() {
        ...
        this.animationSubject.next({ type: this.inAnimation });
        this.inAnimation = AnimationTypes.None;
    }
    ...
}
</script>
(l) Updating our view changing animation to depend on whether we are navigating to a child, parent or neither of the current view.

Overflow in the x direction

  • root
    • src
      • components
        • routing
          • TheRouterOutlet.vue

If we run the translate animations using the mobile device emulator of our browser everything will appear to be running without a hitch. The problem comes if you run it without the emulation. Since we are translating the view in and our horizontally we will see that the horizontal scrollbar appears while the animation is playing. We can fix this by adding a flag that will tell us if there is an animation currently playing and by using that flag to apply a css class. First up is some modification of our template (m). We need to wrap our animatable item in another element and apply a new class to it.

TheRouterOutlet.vue

<template lang="pug">
div.router-view(v-bind:class="{ 'is-animating': isAnimating }")
    AnimatableItem.router-view-animatable(...)
        ...
</template>
(m) Wrapping our animatable item in a div and applying a new class to both.

Next up is modifying our script to include a new field and a computed property. The computed property is just the combination of our previous animating out field and our new animating in field. When an animation is started the animate our animation will complete which will trigger the animating our flag to be set to false. Once that animate in animation is complete the animating in flag will then be set to false as well (n).

TheRouterOutlet.vue

<script lang="ts">
...
export default class TheRouterOutlet extends Vue {
    ...
    private isAnimatingIn = false;
    ...
    private get isAnimating() { return this.isAnimatingIn || this.isAnimatingOut; }
    ...
    private animationComplete() {
        if (this.isAnimatingIn) {
            this.isAnimatingIn = false;
        }
        ...
        this.isAnimatingIn = true;
        ...
    }
    ...
}
</script>
(n) Adding the ability for us to apply a conditional class to our template based on whether an animation is playing.

The final step is to add just a little bit of styling to make sure the horizontal scrollbar is not shown when an animation is running (o).

TheRouterOutlet.vue

<style lang="sass" scoped>
.router-view
    height: 100%
    &.is-animating
        overflow-x: hidden

.router-view-animatable
    height: 100%
</style>
(o) Updating our styling to prevent the horizontal scrollbar from showing when an animation is playing.
Exciton Interactive LLC
Advertisement