Using ChangeDetection With Animation To Setup Dynamic Void Transitions In Angular 2 RC 6
Yesterday, I looked at creating conditional Enter and Leave animations in Angular 2 RC 6. As part of that exploration, I stumbled over the problem of dynamic "leave" states; or rather, starting a "void transition" from a dynamically selected state. To solve that problem, I used explicit change-detection in my component. Since this is a non-obvious approach, I thought it would be worth recapping just that concept in its own follow-up post.
Run this demo in my JavaScript Demos project on GitHub.
Sometimes, in a given user experience (UX), you need to remove an element from the Document Object Model (DOM) based on a user's action. In my previous post, this was necessary to make a carousel widget "cycle" in the appropriate horizontal direction. The problem with this requirement is that you have to set two different view-model values at the same time:
- Set animation state (from which you are leaving).
- Remove the datum from the view rendering.
By default, the latter change prevents the first change from taking place. In other words, when Angular 2 sees that the given data's corresponding DOM element is being destroyed and removed from the view rendering, it won't bother updating the animation state. To fix this problem, we have to run an explicit change-detection in between the two view-model changes:
- Step 1: Change the animation state.
- Step 2: Run explicit change-detection (which applies the new animation state).
- Step 3: Remove the datum from the view rendering.
To see this in action, I've created a small demo that removes a Box element from the view. In all cases, a dynamic animation state is being set as part of the removal process; but, change-detection is only being run in the last 2 removal calls:
// 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";
@Component({
selector: "my-app",
animations: [
trigger(
"boxAnimation",
[
// In this collection of transitions, the initiate state of the animation
// is determined by the boxState expression that is being driven by the
// user interaction.
transition(
"withOpacity => void",
[
style({
opacity: 1.0
}),
animate(
"1000ms ease-in",
style({
opacity: 0.0
})
)
]
),
transition(
"withRotation => void",
[
style({
opacity: 1.0,
transform: "rotate( 0deg )"
}),
animate(
"1000ms ease-in",
style({
opacity: 0.0,
transform: "rotate( 1000deg )"
})
)
]
)
]
)
],
template:
`
<ul>
<li>
<a (click)="removeBox( 'withOpacity' )">Remove w/ Opacity</a>
</li>
<li>
<a (click)="removeBox( 'withOpacity', true )">Remove w/ Opacity + ChangeDetection</a>
</li>
<li>
<a (click)="removeBox( 'withRotation', true )">Remove w/ Rotation + ChangeDetection</a>
</li>
</ul>
<div class="container">
<template [ngIf]="isShowingBox">
<div [@boxAnimation]="boxState" class="box">
Box
</div>
</template>
</div>
`
})
export class AppComponent {
public boxState: string;
public isShowingBox: boolean;
private changeDetectorRef: ChangeDetectorRef;
// I initialize the component.
constructor( changeDetectorRef: ChangeDetectorRef ) {
this.changeDetectorRef = changeDetectorRef;
this.boxState = "none";
this.isShowingBox = true;
}
// ---
// PUBLIC METHODS.
// ---
// I remove the box by first putting the box animation into the given state and then
// updating the flag that removes the box from the view. The `runChangeDetection`
// argument determines whether or not a change-detection is run in between these
// two steps.
public removeBox( fromState: string, runChangeDetection: boolean = false ) : void {
console.group( "removeBox()" );
console.log( "Setting state to:", fromState );
// STEP 1: Set animation state.
// ---
// Set the state that will determine which animation transition will take place
// when the box is removed from the view ( boxState => void ).
this.boxState = fromState;
// STEP 2: Run change detection.
// ---
// Run change-detection if requested. Doing this will apply the boxState change
// BEFORE we try to remove the box from the view.
if ( runChangeDetection ) {
console.log( "Running change-detection." );
this.changeDetectorRef.detectChanges();
}
// STEP 3: Remove box.
// ---
// Remove the box from the view.
this.isShowingBox = false;
console.groupEnd();
// In a few seconds, reset the demo.
setTimeout(
() => {
this.isShowingBox = true;
this.boxState = "none";
},
( 2 * 1000 )
);
}
}
As you can see, the box element is bound to the @boxAnimation trigger. This trigger has two different leave transitions configured:
- withOpacity => void
- withRotation => void
By default, the boxAnimation state is "none" - it only gets set to "withOpacity" or "withRotation" based on the user's action as part of the removal process. If we run this code and use the first removal option, which doesn't run change-detection, we get no transition. That's because Angular never changes the animation state, leaving us with a "none => void" transition, which is not configured in our animation meta-data.
If, however, we choose one of the latter options which does use change-detection, we can clearly see that an animation transition is taking place:
This meta-data based animation stuff is totally new to me; so, I am not quite sure how I feel about it yet. Clearly, there's going to be a large learning curve; and, we have to keep in mind that Angular 2 is still evolving as well. The Angular 2 docs say that animations will eventually be CSS driven [optionally]; part of me hopes that they bring back the "ng-enter" and "ng-leave" CSS classes - I really liked that animation configurations in Angular 1.x were external to the component itself.
Want to use code from this post? Check out the license.
Reader Comments
Thank you for this!
I find forcing a detectChanges() more like a workaround than an actual solution of the problem going on. I opened up an issue in the angular repo about this though it's a bit more related to the animation callbacks. It's been apparently accepted as a bug and added to the v2.0.1 milestone. https://github.com/angular/angular/issues/11712
That said, this little hack is still very useful and saved me tons of hours. Thank you!
@Carlos,
Thank you for the kind words! Glad that I could help; and, glad that the team is viewing some of this kind of stuff as buggy and not just as a fact of life.
The animation stuff is really interesting in Angular 2. I get the sense that it is very powerful; but, at the same time, I feel like the vast majority of my use-cases will be probably be Void <===> Non-Void transitions. That said, as the power becomes more obvious and the functionality becomes more polished, I look forward to seeing what people do with it.
I love your articles Ben, thanks.
Since it's been few months and Angular Animations API's got updates, is there any changes in your approach?
I quickly tired to see if it works removing ChangeDetectorRef in latest version (2.4.1), yet it fails (2nd and 3rd ones act like 1st one)
Is it possible to achieve this in a way other than ChangeDetectorRef usage?
Not that I complain, I think it's nice, just curious since APIs change so fast these days.
@Bogac Guven I tried this with Angular 4.1 and the ChangeDetectorRef approach wasn't working for me either.
Got it working by wrapping the code that's supposed to remove the component in a setTimeout. This pushes the execution of that command to the next change detection cycle ensuring the state property is set before the component is destroyed. In my case it was a UI-Router state change, but with @Ben's example, you could try something like this:
this.boxState = fromState;
setTimeout(() => {
this.isShowingBox = false;
}, 0);
Cheers,
Vlad
Just had an issue with getting animation triggers to work with async pipes and ended up here looking for a solution.
The trigger was not picking up the change in the async pipe. ChangeDetectorRef didn't help, but managed to solve the problem by explicitly setting the changeDetection: ChangeDetectionStrategy.OnPush in the component. Once this was set the trigger worked perfectly with the observable data flowing through the async pipe.