Learn to make use of the awesome new animation features in Angular 4.2+
Angular 4.2 introduces a wave of new animation features that allow for multi-element & reusable animations (with input param support) and as well as full-blown router-level animations.
This set of animation features was originally set for release in 4.1, however, this was then moved to 4.2 to allow give the API space to mature until the final release of 4.2.
It's been a bumpy road for angular's animations DSL. Hopefully these new API features give you the power to animate your Angular apps in ways you never thought of before! :)
Animations have been around since the release of Angular 2 back in September of 2016. While there isn't a full blown article on this on yearofmoo, there are other helpful resources out there that explain how all of it works.
Nethertheless, a short segment on how animations work in Angular would be helpful. Let's break this up into a few short pieces.
Animations live in both the @angular/animations
and @angular/platform-browser/animations
packages of Angular. This means to get access to this stuff, please add the following dependencies
into your package.json
when starting an application.
// package.json
{
"dependencies": {
"@angular/animations": "latest",
"@angular/platform-browser": "latest",
// the rest of the stuff
}
}
Animations are now apart of your project, but not yet enabled for your application. To enable
animations, be sure to add in the BrowserAnimationsModule
into your application.
import {NgModule} from "@angular/core";
import {BrowserAnimationsModule} from "@angular/platform-browser/animations";
import {AppComponent} from './app.component';
@NgModule({
declarations: [AppComponent],
imports: [BrowserAnimationsModule]
})
class AppModule {
}
Now that animations are hooked up in an application, it's time to get animations to render on screen when some kind of action occurs. By action we mean that when some component's value changes we want to have that change kick off an animation.
Animations are fired using animation property bindings which are prefixed with an @
symbol.
<!-- app.component.html -->
<div [@someCoolAnimation]="bindingVar">...</div>
Looking at the example above, when the bindingVar
input value changes then it will notify the someCoolAnimation
code of a state change. It is then up to someCoolAnimation
to handle the state change and therefore kick off a
cool animation. For an animation to fire here, the someCoolAnimation
animation needs to be registered on the component
using a series of animation helper methods.
// app.component.ts
import {Component} from "@angular/core";
import {trigger, transition} from "@angular/animations";
@Component({
templateUrl: 'app.component.html',
animations: [
trigger('someCoolAnimation', [
transition('* => fadeIn', [
// fade in Animation
]),
transition('* => fadeOut', [
// fade out Animation
])
])
]
})
class AppComponent {
bindingVar = '';
fadeIn() {
this.bindingVar = 'fadeIn';
}
fadeOut() {
this.bindingVar = 'fadeOut';
}
toggle() {
this.bindingVar == 'fadeOut' ? this.fadeIn() : this.fadeOut();
}
}
As you can imagine, the transition steps for * => fadeIn
and * => fadeOut
correspond to the state
change when the bindingVar value changes to fadeIn or fadeOut respectively.
What's missing here is the code required to actually animate the animation fadeIn/fadeOut code:
// app.component.ts
import {Component} from "@angular/core";
import {trigger, transition, style, animate} from "@angular/animations";
@Component({
templateUrl: 'app.component.html',
animations: [
trigger('someCoolAnimation', [
transition('* => fadeIn', [
style({ opacity: 0 }),
animate(1000, style({ opacity: 1 }))
]),
transition('* => fadeOut', [
animate(1000, style({ opacity: 0 }))
])
])
]
})
class AppComponent {
//...
}
Alright so now the fadeIn/fadeOut state changes are animated, all that's missing is having a toggle button to make this state change kick off. Let's add that to our template code.
<!-- app.component.html -->
<button (click)="toggle()">Toggle Fade</button>
<div [@someCoolAnimation]="bindingVar">hello there</div>
And now we can get going with the Angular 4.2. stuff.
So the remainder of the guide goes over each of the new animation features. The new animation features in 4.2.0 enable us to:
- Configure options and set input variables within animations
- Define reusable animations using
animation()
- Query for inner elements within animations using
query()
- Stagger multiple elements within an animation using
stagger()
- Enable queried elements to trigger their own animations
- Orchestrate a full-blown animation when routes change
- Programmatically build/control an animation using
AnimationBuilder
Let's get started.
This new wave of animation features is apart of the Angular 4.2.0 release
(at the time of this article the 4.2.3 version was out). Therefore make
sure to have that setup in your package.json
code:
// package.json
{
"dependencies": {
"@angular/animations": "latest",
"@angular/common": "latest",
"@angular/compiler": "latest",
"@angular/core": "latest",
"@angular/forms": "latest",
"@angular/http": "latest",
"@angular/platform-browser": "latest",
"@angular/platform-browser-dynamic": "latest",
"@angular/router": "latest"
}
}
If you want to setup a new CLI project using these animations then follow the instructions below:
npm install @angular/cli -g
# make sure that this is >= 1.0.0
ng --version
# create the new angular application
ng new animationApp
# look into the `package.json` file to see that each
# @angular/dep dependency is 4.2.0 or higher
For the new features introduced in this blog article there is a companion demo application that contains everything in working order. This demo should be familiar as it was presented at ng-conf back in April.
https://github.com/matsko/ng4-animations-preview
Also, here is a live view of the demo in case you want to skip installation and everything.
to topThe first major addition to the animation DSL in Angular 4.2 is the ability to configure/override options for each of the step-based animation methods. These methods are:
sequence([...], { /* options */ })
group([...], { /* options */ })
trigger([...], { /* options */ })
transition([...], { /* options */ })
query([...], { /* options */ })
As mentioned above, each of step-based animation methods support various animation options. How are these values passed in? Well now there is an additional method parameter that takes in all this stuff:
@Component({
animations: [
trigger('someCoolAnimation', [
transition('* => *', [
// the animation itself...
], {
// the animation options
})
])
]
})
Each of the step-based animation methods support the following options:
options.delay
same idea here as with duration only it can postpone the animation from happening until a bit later. Percentage values here are not supported. Negative delays are not supported now but they will be in a future release.
options.params
This is where input params are placed (more explained later).
Options and input params can also be passed into an animation binding value when the animation kicks off.
<div [@animation]="{value: expVal, /* option1: value, option2: value */ }">...</div>
Alright so now that we understand how to pass option data into an animation (or a specific
chunk of a sequence) it's possible to make use of animation input parameters by setting
even more data via the options.params
property.
Input parameters are just substitutions of data that can be applied to style properties
and timing values anywhere in an animation sequence (depending on where the params are
assigned). If a param key/value entry is set in the options.params
object then it can
be "echoed" using the oh-so-familiar {{ binding }}
syntax:
// inside of @Component.animations...
transition('* => *', [
style({ opacity: 0 }),
animate("{{ time }}",
style({ opacity: "{{ opacity }}" }),
], {
time: "1s",
opacity: "1"
})
Keep in mind that input parameter substitutions only work for style property values and timing data (for now). It is also possible to update inner param values with subsitutions as well.
to topRead once then animate...
One important thing to remember is that substitution values are not updated "on the fly". This means that as soon as the animation starts then all the input parameters are evaluated and the animation is built (they do not update as the animation is underway).
At this point you may be wondering what's the real advantage in having these input parameter
substitution mechanisms when we can only place them in animations that exist directly within
the component source code. Well, while it's always possible to create animations that are
imported from other files, however, input parameters be passed in directly during animation
rendering is still not possible this way. What's missing is a way to define and package an
animation into a reusable piece of code that can be used elsewhere alongside full input
parameter support. This is possible using the animation()
animation helper method.
// animations.ts
import {animation, style, animate} from "@angular/animations";
export var fadeAnimation = animation([
style({ opacity: "{{ from }}" }),
animate("{{ time }}", style({ opacity: "{{ to }}" }))
], { time: "1s" })
The reusable fadeAnimation
animation can now be imported into our application and
invoked using the useAnimation
method.
// inside of @Component.animations...
import {useAnimation, transition} from "@angular/animations";
import {fadeAnimation} from "./animations";
transition('* => *', [
useAnimation(fadeAnimation, {
from: 0,
to: 1,
time: '1s'
})
])
And there you go, now you have a super reusable fade animation!
to topAnimations using other animations
Also note that it's totally OK for a reusable animation to call another reusable animation directly within itself. Just don't go making any infinite animation loops.
In case reusable animations do not meet your animation feature standards, maybe the new
query()
and stagger()
features will. query()
allow for multiple elements to be
animated in parallel and stagger()
allows for each queried item to be visually
choreographed with a curtain-like animation effect.
This is the biggest, baddest and most insane animation feature to hit Angular since
ngAnimate
back in the AnuglarJS days.
Long story short, query()
allows for child elements to be collected and animated
against a bunch of animation steps. So instead of having to animate everything on
the element containing the animation trigger, we can now grab inner elements and
make a really cool multi-element animation sequence.
import {query, style, animate} from "@angular/animations";
// the code below is expected to be inside of
// a trigger() and transition() definition
// apply some styling to all inner elements
query('*', style({ opacity: 0 }))
// grab a bunch of things and then animate
query('div, .inner, #id', [
animate(1000, style({ opacity: 1 }))
])
// then reset the state for each element
query('*', [
animate(1000, style('*'))
])
The selector
value that is passed into query supports the following tokens:
Regular CSS Selectors
query('div, #id, .class, [attr], a + b') will return one or more items that match the selector string.
:enter and :leave
calling query(':enter') will return all newly inserted nodes within a container and query(':leave') will return nodes that have been set to be removed.
@trigger and @*
query('@trigger') will find one or more items that have an an animation trigger with that name. query("@*") will return all items that have any animation triggers.
:animating
query(':animating') will collect all elements that are currently animating.
:self
query(':self') will return the container element where the query is issued (this is useful when a container element is to be included in the same animation code along with other queried items)
What about the option values that can be passed into query?
Marking a query as optional
By default the query function will throw an error if it doesn't find any items. Using
{optional:true}
within the query options will ignore the error.Can you limit queried values?
In addition to the
optional
options value, thelimit
option value can be used to cap the amount of elements that are selected.
The query feature is used extensively throughout the animation demo code (mostly
alongside stagger
and animateChild
). Therefore, let's have a look at stagger
so that we can start to understand how the code works in the demo application.
Back with ngAnimate in AngularJS we had the ability to stagger using some
special CSS selector values. This is now possible directly in Angular's animation
DSL code using the stagger()
animation helper method.
Let's say that we have an ngFor
with a container wrapped around it.
<div [@listAnimation]="items.length">
<div *ngFor="let item of items">
{{ item }}
</div>
</div>
See the @listAnimation
animation? Well that code right there is where we will
be querying to find each of the newly inserted nodes that were placed there by
ngFor.
trigger('listAnimation', [
transition('* => *', [
// remember that :enter is a special
// selector that will return each
// newly inserted node in the ngFor list
query(':enter', [
style({ opacity: 0 }),
animate('1s', style({ opacity: 1 }))
])
])
])
In this example we see that the nodes are faded into the page all at the same time. Now if we use a stagger we can space this out so that it animates each step with a nice gap in between:
trigger('listAnimation', [
transition('* => *', [
// this hides everything right away
query(':enter', style({ opacity: 0 })),
// starts to animate things with a stagger in between
query(':enter', stagger('100ms', [
animate('1s', style({ opacity: 1 }))
])
])
])
And vuola! We are now able to apply a stagger animation.
How do you reverse stagger?
This can be done by using a negative number (like
stagger(-1000, [])
) or using thereverse
label in the string-based timing value (likestagger("100ms reverse", [])
).
Now that we understand how this works, let's examine a piece of code from the demo application.
<!-- image-preview.component.html -->
<div *ngFor="let image of activeImages" class="image-container" @image>
<div class="image-info">
<h2>Title</h2>
<p>...</p>
<p>...</p>
</div>
<div class="image-preview">
<app-image [image]="image"></app-image>
</div>
</div>
The code above is the preview area in the demo where an image's contents are displayed
when clicked. Inside of the area that is displayed we have an @image
animation that fires
that will stagger animate in each of the inner contents (the paragraph tags and the image
info). The animation code for that is as follows:
// image-preview.component.ts
trigger('image', [
transition(':enter', [
query('*', [
style({ transform: 'translateX(200px)', opacity: 0 }),
stagger(100, [
animate('1200ms cubic-bezier(0.35, 0, 0.25, 1)', style('*'))
])
])
])
The code right here will run when the item is entered on screen and it will start off by hiding and horizontally offsetting each item on screen. Then it will go through each item and stagger animate them to be visible.
to topWhat does style("*") mean?
When
style("*")
is used it will revert all used styles in the animation back to their end state value (which is the same as setting each value to*
explicitly).
Now what about the situation when there is an element in the application that has an animation ready-to-go, but a parent animation runs instead? Is there a way that the parent animation can dictate when the child animation runs, how long it runs for or if it doesn't run at all?
<!-- do these two guys animate at the same time? -->
<div [@parent]="parentVal">
<div [@child]="childVal">
...
</div>
</div>
Using the animateChild
animation function, the parent animation can allow the child animation
to run at the exact right time.
Let's say that the @parent
and @child
animations look like so:
trigger('parent', [
style({ transform: 'translate(-100px)' }),
animate('500ms', style({ transform: 'translate(0px)' }))
]),
trigger('child', [
style({ opacity:0 }),
animate('0.5s', style({ opacity:1 }))
])
If these animations trigger at the same time then the parent animation will always win and the child
animation will be skipped. However, using animateChild
we can have the parent animation query into
the child and tell it when it's allowed to animate:
trigger('parent', [
style({ transform: 'translate(-100px)' }),
animate('500ms',
style({ transform: 'translate(0px)' })),
query('@ child', animateChild())
]),
trigger('child', [
style({ opacity:0 }),
animate('0.5s', style({ opacity:1 }))
])
And there you have it. Now the parent animation can dictate exactly when the sub animation
runs (if options.duration
is provided then it can also override the duration to speed up,
slow down or skip the animation entirely).
Now that we understand this, let's examine some of the image preview code from the demo application:
@Component({
animations: [ ... ]
})
export class ImagePreviewComponent {
@HostBinding('@preview')
public count: number = 0;
}
This simplified representation of the component code has a host animation binding bound to a counter value that updates each time the current image changes in the selected carousel. Here's what the HTML looks like again:
<!-- image-preview.component.html -->
<div *ngFor="let image of activeImages" class="image-container" @image>
<div class="image-info">
<h2>Title</h2>
<p>...</p>
<p>...</p>
</div>
<div class="image-preview">
<app-image [image]="image"></app-image>
</div>
</div>
Since a host binding is being used here this means that it will run an animation on the entire carousel container.
Let's see what the animation looks like:
trigger('preview', [
//...
transition('* => *', [
query(':enter, :leave', style({ position: 'absolute', left: '0%' })),
query(':enter', style({ left: '100%' })),
group([
query(':leave', group([
animateChild(),
animate('1200ms cubic-bezier(0.35, 0, 0.25, 1)', style({ opacity:0, left:'-100%' }))
])),
query(':enter', group([
animate('1200ms cubic-bezier(0.35, 0, 0.25, 1)', style('*')),
animateChild()
]), { delay: 200 }),
])
])
])
So there's alot going on here, but just about all of the querying occurs on the :enter
and :leave
nodes (which are the nodes inserted by ngFor
). Once the animation styles are setup, a group animation
(using group
) is run which then animates the leave and enter items together in parallel. It is
in this code that animateChild
is called which then allows the @image
animation code to run.
to topWhat about the starting styles for the inner animation?
One thing that isn't obvious until you run the animation is the what the starting visual state of the content will be before
animateChild()
is run (for the sub animation elements). Logically speaking it should just be visible right? Well yes, but Angular knows that this happens so it applies the very first frame of the inner animation at the start of the parent animation to the sub animation element(s). This way the parent animation doesn't have to have any idea of how to style the inner sub animation before its called (but it still can if it wants).
Seeing as the query and animateChild methods are designed to allow inner animations to fire at a convenient time within an animation sequence, it would make perfect sense to allow for the router to activate an animation for pages that are inserted/removed into the viewport. In other words, wouldn't it be cool if it were possible to allow for the router to animate the construction and destruction of views as navigation occurs between routes? Let's give it a try.
Due the mechanics of the <router-outlet>
directive, we can't just place an
animation trigger directly on it since it the router component views are not inserted directly
inside of it (they are placed right after where the router outlet element is). Therefore what
we need to do is wrap a div element around the router outlet so we can have a parent element
that handles all the animations based on the state of the router.
<!-- app.html -->
<div [@routeAnimation]="prepRouteState(routerOutlet)">
<!-- make sure to keep the ="outlet" part -->
<router-outlet #routerOutlet="outlet"></div>
<div>
Notice how we're grabbing ahold of a local variable called routerOutlet
? Well
this is the special guy who contains the details about what page/route is active.
The outlet variable is passed into the prepRouteState function which
is used to decide what state value to pass into the routeAnimation
animation.
// app.ts
@Component({
animations: [
trigger('routeAnimation', [
// no need to animate anything on load
transition(':enter', []),
// but anytime a route changes let's animate!
transition('* => *', [
// we'll fill this in later
])
])
]
})
class AppComponent {
prepRouteState(outlet: any) {
return outlet.activatedRouteData['animation'] || 'firstPage';
}
}
The prepRouteState
function takes in the outlet and returns a string
value representing the state of the animation based on the custom data of the
current active route. So as you can imagine, for this to work, we need to specify
an animation state value directly in the spot where each of our routes are defined.
Let's see what our routing definition code looks like:
<!-- app.ts -->
const ROUTES = [
{ path: '',
component: HomePageComponent,
data: {
animation: 'homePage'
}
},
{ path: 'support',
component: SupportPageComponent,
data: {
animation: 'supportPage'
}
}
]
See how the animation
property is defined inside of the route's data object? Yes
this is the state of the route that we'll be using within our routeAnimation
trigger transition code.
trigger('routeAnimation', [
// no need to animate anything on load
transition(':enter', []),
// when we go away from the home page to support
transition('homePage => supportPage', [
// ...
]),
// and when we go back home
transition('supportPage => homePage', [
// ...
])
])
Now we're talking. Let's animate some stuff!
Just as we have been using :enter
and :leave
each time we've used the query code,
we will be using it again here.
So as the routes change, we want to grab ahold of the old and new pages that are apart
of the route change. So if our animation goes from homePage => supportPage
then the
homePage can be queried by :leave
and the supportPage
can be queried by :enter
.
trigger('routeAnimation', [
//...
transition('homePage => supportPage', [
// make sure the new page is hidden first
query(':enter', style({ opacity: 1 })),
// animate the leave page away
query(':leave', [
animate('0.5s', style({ opacity: 0 })),
style({ display: 'none' })
]),
// and now reveal the enter
query(':enter', animate('0.5s', style({ opacity: 1 }))),
]),
//...
])
The code above will animate out the homePage first and then animate in the supportPage.
If we use group()
here then can have this animation run parallel
trigger('routeAnimation', [
//...
transition('homePage => supportPage', [
group([
query(':enter', [
style({ opacity: 0 }),
animate('0.5s', style({ opacity: 1 }))
]),
query(':leave', [
animate('0.5s', style({ opacity: 0 }))
])
])
]),
//...
])
Now each page might even have its own animation that can be called when an enter or
a removal happens. This means that we can use animateChild
to kick off animations
at the right time.
// the animation itself
trigger('routeAnimation', [
//...
transition('homePage => supportPage', [
group([
query(':enter', [
style({ opacity: 0 }),
animate('0.5s', style({ opacity: 1 })),
animateChild()
]),
query(':leave', [
animate('0.5s', style({ opacity: 0 })),
animateChild()
])
])
]),
//...
])
And that's how router-level animations are run.
to topThe final new feature is the addition of programmatic animation construction using the
injectable AnimationBuilder
service (yes inside of components).
Building animations within components is much more low-level than using animation triggers. The reason for this is because the animation trigger code does a lot of work to keep track of state and to automatically cancel animations when a new one starts. All of this is out the window with when AnimationBuilder is used. But fear not, a player object is returned each time an animation is run and that player can be used to control the state of the animation.
So how is AnimationBuilder used? Well once injected into a component, then it can be used directly to create an animation:
import {AnimationBuilder} from "@angular/animations";
@Component({...})
class MyCmp {
constructor(public builder: AnimationBuilder) {}
animate() {
// this makes instructions on how to build the animation
const factory = this.builder.build([
// the animation
]);
// this creates the animation
const player = factory.create(this.someElement);
// start it off
player.play();
}
}
In the demo application code we have a modal that pops up which contains a loading indicator to represent the progress state of the upload. The progress state value itself is applied using a programmatic animation built directly in the component.
import {AnimationBuilder, AnimationPlayer} from "@angular/animations";
@Component({
selector: 'loader',
template: `
<div class="loading-stage">
<div class="loading-bar" #loadingBar>
{{ percentage }}%
</div>
</div>
`
})
export class LoaderComponent {
@ViewChild('loadingBar')
public loadingBar;
public player: AnimationPlayer;
private _percentage = 0;
constructor(private _builder: AnimationBuilder) {}
get percentage() { return this._percentage; }
@Input('percentage')
set percentage(p: number) {
this._percentage = p;
if (this.player) {
this.player.destroy();
}
const factory = this._builder.build([
style({ width: '*' }),
animate('350ms cubic-bezier(.35, 0, .25, 1)', style({ width: (p * 100) + '%' }))
]);
this.player = factory.create(this.loadingBar.nativeElement, {});
this.player.play();
}
}
The usage of the loader component looks like so:
<loader [percentage]="percentageValue"></loader>
The percentage value will be applied when the binding changes and that will in turn call the percentage setter within the component. It's in that setter code that the animation will be built and run.
set percentage(p: number) {
// this builds the animation
const factory = this._builder.build([
style({ width: '*' }),
animate('350ms cubic-bezier(.35, 0, .25, 1)', style({ width: (p * 100) + '%' }))
]);
// and this will will create a player for that animation
// (notice how the loading bar is referenced)
this.player = factory.create(this.loadingBar.nativeElement, {});
}
Now the player can be interacted with directly to play/pause/reset and destroy the animation.
Oh and scrubbing is possible by pausing the player and using player.setPosition(percentage)
.
Thank you for reading this article and for testing out the new animation features. In case you come across any bugs please report them to the github angular/angular project's issue page.
to top