Experimenting With Conditional Enter-Leave Animations In Angular 2 RC 6
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 }}
—
Favorite Movie: {{ friend.favoriteMovie }}
</div>
</div>
</template>
</div>
<p class="controls">
«
<a (click)="showPrevFriend()">Previous Friend</a>
—
<a (click)="showNextFriend()">Next Friend</a>
»
</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:
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.
Want to use code from this post? Check out the license.
Reader Comments
Awesome tutorial!
@Pradeep,
Thank you very much :)
@All,
I put together a quick follow-up demo pointing out the .detectChanges() part of this post:
www.bennadel.com/blog/3140-using-changedetection-with-animation-to-setup-dynamic-void-transitions-in-angular-2-rc-6.htm
It felt quirky enough to pull-out into its own spotlight.
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..!!)
@Raja,
Thank you good sir :) Much appreciated.
Great stuff as always...
I just changed to the sugar version of *ngFor
https://github.com/born2net/ng2Boilerplate/blob/master/src/comps/app2/notes/AnimateCards.ts
Angular 2 Kitchen sink: http://ng2.javascriptninja.io
and source@ https://github.com/born2net/ng2Boilerplate
Regards,
Sean
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.
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!
That actually would make for a great post, how to make the router simply fade in and fade out outlets as a default behavior...
@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.
@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 ).
@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.
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.
I see, tx!!!
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 :)
Hey Ben many thanks for the article, fantasitic read!!
I was just wondering if in Angular 2.1.0 is it possible to detect (via callback) when a transition becomes into and out of the "void" state:?
@Bromel,
That's a great question. I know that you can listen for end-of-transition events now; but, I haven't actually experimented with it yet. I think that would be helpful for complex, serialized animations (ie, wait for this thing to hide, then show this thing, then when done, show this other thing). But, I haven't had much use-case for that kind of stuff. But, it is something that I'd like to play with.
Hi ben
Yes that is exactly what I had in mind. I tend to use other javascript libraries for doing animation sequences e.g (Greensock Application Platform) which has a lot more scope to do the complex and serialised animations. So if it was possible to detect into and out of the void state it would allow you to decouple from the web animation api and use whatever library/platform of you choosing. Don't get me wrong I appreciate what the Angular team have done so far by incorporating an emerging standard but UI/UX animations do tend to have complexity about them and I can see that creating complex animations in Angular today would incorporate a lot of code. But if you get chance to have a play them give me a shout and i'll show the power of GSAP.
regards ;)
Hi ben,
Thank you ver much for this post. The "changeDetectorRef" solution for apply changes on stages saved me a lot of waste time. I think this is not very well explain in the docs. Where do you find it?
Hi Ben, loving the creative ideas! I'm still learning Angular but I've been trying to get something similar for my route component//page animations.
Say I have a five 'page' app, with each 'page' stacked up so that it's kind of a set of five vertical slides (e.g. Home // About // Contact // Gallery // FAQ). I'm passing states into the transition, and clicking the link to About from Home animates Home out to the top and About in from the bottom (via a state "scrollUp"). However, with the current state already "scrollUp", when I then click Contact from About for the same transition again it doesn't animate as the 'current' and 'target' state are the same ("scrollUp")..
Do you know of a way to force the current state to be void so that 'page' transitions can be repeated? Cheers man, really appreciate it! Let me know if the above doesn't make sense - might not have articulated it the best!