Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at cf.Objective() 2013 (Bloomington, MN) with: Jason Dean
Ben Nadel at cf.Objective() 2013 (Bloomington, MN) with: Jason Dean@JasonPDean )

Experimenting With Conditional Enter-Leave Animations In Angular 2 RC 6

By Ben Nadel on

While Angular 2 now has support for animations, the approach taken in Angular 2 is very different from the approach taken in Angular 1.2+. In Angular 1.2+, the animations are driven by special CSS classes that are parsed at runtime; in Angular 2 (at least as of RC 6), all animations are driven by animation meta-data that is attached to the component. To start wrapping my head around this new animation framework, I wanted to try and recreate my ngRepeat-based animation "hack" in Angular 2 RC 6, using conditional "Enter" and "Leave" animations based on user interaction.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

NOTE: The animation framework in Angular 2 uses the Web Animations API which still needs to be polyfilled in most browsers - I have added it to my package.json for RC 6.

In Angular 2, the animation framework is really a state-transition framework. However, when I think about animations for dynamically-rendered elements, there are really only two states that I care about: "existing" and "not existing". In Angular 1.2+, transitions between these two states were facilitated by the "leave" and "enter" animations. In Angular 2, this becomes a transition into and out of the "void" state:

  • Enter: "void" => "*"
  • Leave: "*" => "void"

In Angular 2, the transition into and out of this "void" state can change depending on what state an element is being transitioned from or to, respectively. To experiment with this, I'm going to use the ngFor directive to hack a "cycle" widget that will show the selected-friend in a collection of friends. As the user cycles through this collection, they can navigate to the previous-friend or the next-friend. Depending on which operation is chosen, I want the animating elements to move in a visual direction that aligns properly with the user's own mental model.

In other words, when the user navigates to the previous-friend, I want the elements on the page to animate right; and, when the user navigates to the next-friend, I want the elements on the page to animate left. This means that the void-related transitions will depend on the state of the animation trigger at the time of the navigation.

In the following demo, there are four transition that an element can have:

  • void => prev - an element enters left-to-right.
  • prev => void - an element leaves left-to-right.
  • void => next - an element enteres right-to-left.
  • next => void - an element leaves right-to-left.

The first two are used when the user navigates to the previous-friend, "sliding" the collection to the right. The second two are used when the user navigates to the next-friend, "sliding" the collection to the left. Let's take a look at the code:

  • // Import the core angular services.
  • import { animate } from "@angular/core";
  • import { ChangeDetectorRef } from "@angular/core";
  • import { Component } from "@angular/core";
  • import { style } from "@angular/core";
  • import { transition } from "@angular/core";
  • import { trigger } from "@angular/core";
  •  
  • interface Friend {
  • id: number;
  • name: string;
  • favoriteMovie: string;
  • }
  •  
  • type Orientation = ( "prev" | "next" | "none" );
  •  
  • @Component({
  • selector: "my-app",
  • animations: [
  • trigger(
  • "friendAnimation",
  • [
  • transition(
  • "void => prev", // ---> Entering --->
  • [
  • // In order to maintain a zIndex of 2 throughout the ENTIRE
  • // animation (but not after the animation), we have to define it
  • // in both the initial and target styles. Unfortunately, this
  • // means that we ALSO have to define target values for the rest
  • // of the styles, which we wouldn't normally have to.
  • style({
  • left: -100,
  • opacity: 0.0,
  • zIndex: 2
  • }),
  • animate(
  • "200ms ease-in-out",
  • style({
  • left: 0,
  • opacity: 1.0,
  • zIndex: 2
  • })
  • )
  • ]
  • ),
  • transition(
  • "prev => void", // ---> Leaving --->
  • [
  • animate(
  • "200ms ease-in-out",
  • style({
  • left: 100,
  • opacity: 0.0
  • })
  • )
  • ]
  • ),
  • transition(
  • "void => next", // <--- Entering <---
  • [
  • // In order to maintain a zIndex of 2 throughout the ENTIRE
  • // animation (but not after the animation), we have to define it
  • // in both the initial and target styles. Unfortunately, this
  • // means that we ALSO have to define target values for the rest
  • // of the styles, which we wouldn't normally have to.
  • style({
  • left: 100,
  • opacity: 0.0,
  • zIndex: 2
  • }),
  • animate(
  • "200ms ease-in-out",
  • style({
  • left: 0,
  • opacity: 1.0,
  • zIndex: 2
  • })
  • )
  • ]
  • ),
  • transition(
  • "next => void", // <--- Leaving <---
  • [
  • animate(
  • "200ms ease-in-out",
  • style({
  • left: -100,
  • opacity: 0.0
  • })
  • )
  • ]
  • )
  • ]
  • )
  • ],
  • template:
  • `
  • <div class="container">
  • <template ngFor let-friend [ngForOf]="[ selectedFriend ]">
  •  
  • <div [@friendAnimation]="orientation" class="friend">
  •  
  • <div class="name">
  • {{ friend.name }}
  • </div>
  • <div class="avatar"></div>
  • <div class="meta">
  • ID: {{ friend.id }}
  • &mdash;
  • Favorite Movie: {{ friend.favoriteMovie }}
  • </div>
  •  
  • </div>
  •  
  • </template>
  • </div>
  •  
  • <p class="controls">
  • &laquo;
  • <a (click)="showPrevFriend()">Previous Friend</a>
  • &mdash;
  • <a (click)="showNextFriend()">Next Friend</a>
  • &raquo;
  • </p>
  • `
  • })
  • export class AppComponent {
  •  
  • public orientation: Orientation;
  • public selectedFriend: Friend;
  •  
  • private changeDetectorRef: ChangeDetectorRef;
  • private friends: Friend[];
  •  
  •  
  • // I initialize the component.
  • constructor( changeDetectorRef: ChangeDetectorRef ) {
  •  
  • this.changeDetectorRef = changeDetectorRef;
  • this.orientation = "none";
  •  
  • // Setup the friends collection.
  • this.friends = [
  • {
  • id: 1,
  • name: "Sarah",
  • favoriteMovie: "Happy Gilmore"
  • },
  • {
  • id: 2,
  • name: "Joanna",
  • favoriteMovie: "Better Than Chocolate"
  • },
  • {
  • id: 3,
  • name: "Tricia",
  • favoriteMovie: "Working Girl"
  • },
  • {
  • id: 4,
  • name: "Kim",
  • favoriteMovie: "Terminator 2"
  • }
  • ];
  •  
  • // Randomly(ish) select the initial friend to display.
  • this.selectedFriend = this.friends[ Math.floor( Math.random() * this.friends.length ) ];
  •  
  • }
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I cycle to the next friend in the collection.
  • public showNextFriend() : void {
  •  
  • // Change the "state" for our animation trigger.
  • this.orientation = "next";
  •  
  • // Force the Template to apply the new animation state before we actually
  • // change the rendered element view-model. If we don't force a change-detection,
  • // the new [@orientation] state won't be applied prior to the "leave" transition;
  • // which means that we won't be leaving from the "expected" state.
  • this.changeDetectorRef.detectChanges();
  •  
  • // Find the currently selected index.
  • var index = this.friends.indexOf( this.selectedFriend );
  •  
  • // Move the rendered element to the next index - this will cause the current item
  • // to enter the ( "next" => "void" ) transition and this new item to enter the
  • // ( "void" => "next" ) transition.
  • this.selectedFriend = this.friends[ index + 1 ]
  • ? this.friends[ index + 1 ]
  • : this.friends[ 0 ]
  • ;
  •  
  • }
  •  
  •  
  • // I cycle to the previous friend in the collection.
  • public showPrevFriend() : void {
  •  
  • // Change the "state" for our animation trigger.
  • this.orientation = "prev";
  •  
  • // Force the Template to apply the new animation state before we actually
  • // change the rendered element view-model. If we don't force a change-detection,
  • // the new [@orientation] state won't be applied prior to the "leave" transition;
  • // which means that we won't be leaving from the "expected" state.
  • this.changeDetectorRef.detectChanges();
  •  
  • // Find the currently selected index.
  • var index = this.friends.indexOf( this.selectedFriend );
  •  
  • // Move the rendered element to the previous index - this will cause the current
  • // item to enter the ( "prev" => "void" ) transition and this new item to enter
  • // the ( "void" => "prev" ) transition.
  • this.selectedFriend = this.friends[ index - 1 ]
  • ? this.friends[ index - 1 ]
  • : this.friends[ this.friends.length - 1 ]
  • ;
  •  
  • }
  •  
  • }

When I was first coding this demo, I only had half of the [current] styles defined. Meaning, when entering an element, I only had the initial styles defined; and, when existing an element, I only had the final styles defined. Somehow, Angular 2 just knew how to automatically transition between the "transition" styles and the "static" styles.

But, when I added the need for the transitioning element to maintain a zIndex:2 throughout the transition (so that the entering element was always on the "top" of the z-index stack), things got more complicated. Once I added the zIndex, I had to define it in both the initial and the target styles of the transition; otherwise, it would end-up transitioning the value from 2-to-0. Unfortunately, this prevented the other styles - like opacity - from being transitioned automatically; as such, I had to end-up explicitly defining all the initial and target styles. Ultimately, however, I think this explicit styling makes it more clear to the developer.

The other challenge that I faced in this demo was that Angular wouldn't apply an animation trigger state-change to an element that was being removed from the DOM (Document Object Model). Luckily, I discovered that if I requested a .detectChanges() event on the ChangeDetectorRef after defining a state-change in the view-model, Angular 2 would apply the state change to the template before it removed the DOM element.

All in all, I think I got it working quite nicely. And, when we run this in browser, we can clearly see the elements entering and leaving from the correct orientation based on the user interactions:


 
 
 

 
 Conditional enter and leave transition animations based on user interactions in Angular 2 RC 6. 
 
 
 

The shift from CSS-based animations in Angular 1.2+ to meta-data-based animations in Angular 2 is a rather large one. Definitely a lot to learn, especially when it comes to nested animations. But, at least I was able to figure out how to create conditional Enter and Leave animations based on user interactions.




Reader Comments

Hi bro,
I wrote an article explaining animations in Angular 2 on my site; and I kind of felt that I couldn't explain this phenomenon well to my readers. Thanks for enlightening me about this and how to handle this using change detection. Great article. (And great video too..!!)

Reply to this Comment

Hi Ben,

I've followed the article and did implementation according to it. I'm getting next item and previous item, but animation is not working somehow. Tried many changes, with & without state, using just linear instead of ease-in-out, but none of animation is working.

Any clue what I might be missing... I'm using chrome browser.

Reply to this Comment

Hello, Ben
First of all congrats for your blog, I always end somehow landing around here, hehe...
My question:
I am trying to use animations for enter-leave (slide-in, slide-out) routing components, I mean: the user gets there using the router and the particular component will be loaded inside that <outlet> thing-o...
For whatever reason it animates when loading, but it doesn't when leaving the component to another route.
Do you know if that is even possible to achieve?
Thank you!

Reply to this Comment

That actually would make for a great post, how to make the router simply fade in and fade out outlets as a default behavior...

Reply to this Comment

@Paresh,

Hmm, if you're using Chrome, then the animation API should be supported natively - my first thought was that you weren't using the polyfill, but you shouldn't need it.

Perhaps you are missing CSS information about the positioning of the cards. You'll only get the animation if the "left" CSS actually applies; which will only apply if the card is positioned absolutely (in this case):

https://github.com/bennadel/JavaScript-Demos/blob/master/demos/directional-animation-angular2/demo.css

Hopefully that is why. Otherwise, I am not sure.

Reply to this Comment

@Marcos, @Sean,

That's a great idea for a post. I spent about 6 weeks digging into the router (both the Component Router and the now-defunk ngRx Router); but that was before I started looking at any animation stuff. I'll definitely do a follow-up post on that. This is something I've been asked to do (in general) for work as well (though we're still on ng 1.2 at work :D ).

Reply to this Comment

@Sean,

It's funny, I go back and forth on using the <template> tag instead of using the *-sugar. Sometimes, I find that using the Template tag really helps to separate out the concerns of what is going on. And, also puts fewer attributes on any single element.

One of the places where I've really started to use it is with the *ngIf, especially when there are two block for True/False conditions:

<template [ngIf]="someExpression">
. . . <div>
. . . . . . Some dynamic content for TRUE.
. . . </div>
</template>

<template [ngIf]="! someExpression">
. . . <div>
. . . . . . Some dynamic content for FALSE.
. . . </div>
</template>

This way, as the inner Div elements get more "stuff" on them, like Class and [style] attributes, for example, the ngIf is clearly pulled out.

This is just an evolving personal preference - not saying it is right or wrong. But, just something that I'm starting to do more often.

Reply to this Comment

Man, I really need to implement some sort of back-tick code-block functionality :| Actually some sort of light markdown functionality is really just ... mandatory these days.

Reply to this Comment

Gaahhh, forcing the "detectChanges" was exactly what I needed, gladly you figured that out somehow and shared with us, thank you for that! Keep it coming please :)

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.