Using Animation Callbacks When Animation Transitions Are Interrupted In Angular 2.4.4
The other day, I was working on an Angular 2 component that implemented an animation that could be interrupted by a subsequent state change. If the animation was allowed to complete naturally, everything worked fine. But, in the case that the animation was interrupted, I started getting change detection errors regarding the animation's trigger state:
Expression has changed after it was checked. Previous value: 'blue'. Current value: 'none'.
After debugging the problem, I realized that the issue was in my animation's "done" callback. Not only did the callback get invoked more than I realized it would, the callback "$event" didn't always line up with the current state of the component. Once I understood what was going on, it was easy to fix; but, it seemed like something worth exploring.
Run this demo in my JavaScript Demos project on GitHub.
To explore the "done" callback for Angular 2 animations, I've created a simple demo that has a box that can transition to one of three states:
- none (the default state)
- red
- blue
The box always starts and ends in the "none" state. If it transitions from the "none" state to the "red" or "blue" states, I want it to immediately transition back to the "none" state once the animation is complete. As a user, you can trigger the "none => red" transition by clicking on the box. And, if you click on the box while the "none => red" transition is still active, it will switch over to the "red => blue" transition.
In order to get the box to always return to the "none" state, I have to hook into the animation's "done" callback, where I can explicitly set the trigger state back to "none" (after it has been "red" or "blue"). The problem with this is that "done" callback is invoked whenever a transition ends, even if the "overall animation" is still running. This means that if I initiate a "none => red" transition and then interrupted it, triggering a "red => blue" transition, I get the following sequence of events:
- Click box while trigger state is "none".
- Change trigger state to "red".
- Start transition "none => red".
- Click box while trigger state is "red" (ie, mid-transition).
- Change trigger state to "blue".
- Angular invokes "done" callback for "none => red" transition.
- Start transition "red => blue".
- Angular invokes "done" callback for "red => blue" transition.
As you can see, the animation trigger's "done" callback is invoked twice, once for the interrupted transition and once for the final transition. Because of this, I can't blindly reset the trigger state in the "done" callback. Doing so would break the change detection workflow:
- Click box to change trigger state from "red" to "blue" -> Initiates change detection.
- Change detection kills current transition, calls "done" callback.
- Callback blindly sets trigger state back to "none".
... which is where we would get the Angular 2 error change detection error:
Expression has changed after it was checked. Previous value: 'blue'. Current value: 'none'.
To get around this, we have to add some logic to our animation's "done" callback that only sets the trigger state back to the default state - "none" - if the callback wasn't invoked as part of an interrupted transition. And, to do this, we have to compare the callback event to the current state of the component.
If you look at the sequence of events above, you can see that we change the state of the component first, which then implicitly interrupts the active transition. This means that in the case of an interrupted transition, the "done" event's "toState" will not match the current trigger state. As such, we can be sure to only revert the trigger state back to the default state - "none" - in the case where the callback's "toState" matches the component's current state, indicating a "completed" transition.
Let's take a look at the code. In this case, the animation's "done" callback is the public component method, handleDone():
// Import the core angular services.
import { animate } from "@angular/core";
import { AnimationTransitionEvent } from "@angular/core";
import { Component } from "@angular/core";
import { state } from "@angular/core";
import { style } from "@angular/core";
import { transition } from "@angular/core";
import { trigger } from "@angular/core";
@Component({
moduleId: module.id,
selector: "my-app",
animations: [
trigger(
"thing",
[
state(
"none",
style({
backgroundColor: "white",
color: "black"
})
),
state(
"red",
style({
backgroundColor: "red",
color: "white"
})
),
state(
"blue",
style({
backgroundColor: "blue",
color: "white"
})
),
transition( "none => red", animate( "2000ms ease-in-out" ) ),
transition( "red => blue", animate( "2000ms ease-in-out" ) )
]
)
],
styleUrls: [ "./app.component.css" ],
template:
`
<div
[@thing]="thingState"
(@thing.done)="handleDone( $event )"
(click)="animateThing()"
class="box">
I am a thing!
</div>
`
})
export class AppComponent {
public thingState: string;
// I initialize the app component.
constructor() {
// Thing can be in the following states:
// - none (default)
// - red
// - blue
this.thingState = "none";
}
// ---
// PUBLIC METODS.
// ---
// I initiate a state transition for the Thing.
public animateThing() : void {
var previousState = this.thingState;
// If the Thing is in a default state, animate to RED; however, if we are
// currently in the RED state, then animate over to the BLUE state.
this.thingState = ( this.thingState === "none" )
? "red" // none => red
: "blue" // red => blue
;
console.log( "Initiating animation to state:", previousState, "=>", this.thingState );
}
// I get called when an animation has completed. Completion may be caused by an
// animation reaching its end-state; or, it may be called because the state changed
// in the middle of an active transition.
public handleDone( event: AnimationTransitionEvent ) : void {
console.group( "Done animating" );
console.log( "From:", event.fromState );
console.log( "To:", event.toState );
console.log( "Actual State:", this.thingState );
console.groupEnd();
// If the animation was allowed to complete fully, then the event.toState should
// match the actual state of the trigger (and the event.totalTime should be
// accurate). HOWEVER, if the current transition was interrupted, and the "done"
// event is just a byproduct of that premature finish, then the event.toState
// will NOT MATCH the current state (and the event.totalTime will not be accurate).
if ( ( this.thingState !== "none" ) && ( this.thingState === event.toState ) ) {
this.thingState = "none";
}
}
}
As you can see, in the handleDone() method, we compare the event.toState to the current trigger state. If the two values match, we know the transition completed "successfully", which means we can revert the state back to "none". However, if the two values do not match, we know that the transition completed "unsuccessfully" due to an interruption of the previous transition. In that case, we leave the state alone, knowing it is being managed by the subsequent transition.
If we run this app and trigger a "red" state followed immediately by a "blue" state, we can see that the "done" handler gets called multiple times:
As you can see, the "done" callback gets invoked twice, once for the interrupted "none => red" transition and once for the completed "red => blue" transition. And, we can see from the logging that the event.toState does not match the trigger state when interrupted.
I'm still finding the animations in Angular 2 to be a huge mental shift from the simple CSS-based animations of Angular 1. I understand that the Angular 2 animations are more flexible and portable to other technologies; but, they are not simple. But, after ever stumble, my mental model grows stronger!
Want to use code from this post? Check out the license.
Reader Comments
It seems Angular can not be a good choice for animation because it should be rendered by NPM and etc. It does not show the effect immediately on the client browser.
@Ali,
I am not sure I understand exactly what you mean? Are you saying that the animations are being rendered on the server-side? I think there might be a disconnect here - the animations are taking place in the browser. You can see this if you click on the "Run this demo" link near the top of the article.
I have not said it should be rendered on server side, but I have felt it is a bit heavy because it should be compiled by NPM interpreter. JQuery can be a better choice.
Hi Ben
I think you will like this:
https://www.youtube.com/watch?v=oV8b-rlyMdI&spfreload=10
Angular 2 Kitchen sink: http://ng2.javascriptninja.io
and source@ https://github.com/born2net/Angular-kitchen-sink
Regards,
Sean
@Ali,
jQuery could be an option, for sure. Though, if you go the jQuery route, you'll have to get a reference to the actual DOM-node, which we don't need if we use the Angular 2 animations meta-data.
@Sean,
Interesting - I have not tried to create decorator yet in TypeScript. But, they seem kind of cool.
Have the same issue with 'done' phase. Your article helped me, thanks a lot!
@Nadiia,
Awesome, glad to hear that :D
I am having a similar problem. In my case, I have 2 states: 'true' and 'false'. The animation is bound to a flyout menu. 'true' for open, and 'false' for closed. When I set the state to true, the open animation begins, but if I set the state to false before the open animation is complete, the open animation completes, and the close animation never triggers, and I'm left with a UI that's out of sync with the component state. Any ideas?
Scratch that! I upgraded @angular/animations to 4.4.3 and the issue has been resolved.
It seems every time I hit my head on an angular problem and google long enough, I see your smiling face come up with a blog post that is thoughtfully constructed. Thanks for putting in the effort. While I haven't run into this particular problem yet, I'm sure it's coming and I've made a mental bookmark of this post.
@Jon,
Thank you so much for the kind words :D you rock!