This article is intended to be the beginning of a short series about Angular transition animations mechanism source code, a niche in Angular codebase often considered confusing and not enough documented.
Angular transition animations
For "Transition Animations" in Angular we mean adding transitioning effects between elements' state changes.
Normally this is done by binding a trigger to a property that will get the values of the states whose transition we want to animate.
They share only last part of their flow with "Timeline Animations", the ones executed by explicitly operating Animation Players, and that will not be a topic of these articles.
Quick recap on Angular Renderers
To manipulate the DOM without directly accessing its nodes Angular leverages Renderers, tools designed to be a level of abstraction on native DOM operations.
They're shaped upon abstract class Renderer2, and are fundamental in transition animations context because some of their flavors get used to start animation flow.
They get not instanciated manually, but generated by a RendererFactory2.
Animations module
First step to start using animations is importing BrowserAnimationsModule.
This module, among other things, will provide some concrete implementations of aforementioned abstract classes, adding animation capabilities to component creation logic.
Above all, it will ship AnimationRendererFactory that, together with some Renderer implementations it's capable to return, will supersede the ones provided by BrowserModule.
(For the sake of simplicty this series will take in account only the Browser platform, used to build webapps).
export function instantiateRendererFactory(
renderer: DomRendererFactory2, engine: AnimationEngine, zone: NgZone) {
return new AnimationRendererFactory(renderer, engine, zone);
}
const SHARED_ANIMATION_PROVIDERS: Provider[] = [
{provide: AnimationBuilder, useClass: BrowserAnimationBuilder},
{provide: AnimationStyleNormalizer, useFactory: instantiateDefaultStyleNormalizer},
{provide: AnimationEngine, useClass: InjectableAnimationEngine}, {
provide: RendererFactory2,
useFactory: instantiateRendererFactory,
deps: [DomRendererFactory2, AnimationEngine, NgZone]
}
];
AnimationRendererFactory creating animation renderer
The gist of this factory is that it gets injected with an AnimationEngine used by its generated renderers to enrich DOM operations with animated effects.
@Injectable()
export class AnimationRendererFactory implements RendererFactory2 {
...
constructor(
private delegate: RendererFactory2, private engine: AnimationEngine, private _zone: NgZone)
Another important dependency passed to its constructor, is the property called delegate typed as RendererFactory2, populated with an instance of DomRendererFactory2.
This is an implementation of Delegation Pattern used to compose an enhanced RendererFactory.
Normal non-animated DOM operations will be delegated to the original DefaultDomRenderer2 instances generated by this delegate factory, while the new delegator animated renderers will take care of animation tasks only.
Being this class a factory of renderers, its main functionalities reside in createRenderer method.
It accepts two arguments:
- the hostElement being the "first ancestor" of all the elements included in our template (think of the element identified by your component's selector)
- a type typed as RendererType2 (I know... sorry), an object storing some rendering information, mostly retrieved from your
@Component
decorator's metadata.
Looking at its early lines we can understand how a basic animation renderer is created for components not registering any animation.
createRenderer(hostElement: any, type: RendererType2): Renderer2 {
const EMPTY_NAMESPACE_ID = '';
// cache the delegates to find out which cached delegate can
// be used by which cached renderer
const delegate = this.delegate.createRenderer(hostElement, type);
if (!hostElement || !type || !type.data || !type.data['animation']) {
let renderer: BaseAnimationRenderer|undefined = this._rendererCache.get(delegate);
if (!renderer) {
// Ensure that the renderer is removed from the cache on destroy
// since it may contain references to detached DOM nodes.
const onRendererDestroy = () => this._rendererCache.delete(delegate);
renderer =
new BaseAnimationRenderer(EMPTY_NAMESPACE_ID, delegate, this.engine, onRendererDestroy);
// only cache this result when the base renderer is used
this._rendererCache.set(delegate, renderer);
}
return renderer;
}
- first it uses the factory delegate to generate a pure "animation-unaware" DOMRenderer and store it as a local
delegate
variable (don't get confused: global delegatethis.delegate
refers to factory delegate, while (unfortunately) homonymous localconst delegate
point to the newly generated renderer delegate) - it checks for any trigger listed inside animations property of @Component's decorator (after verifying the creation request is not relative to an hostRenderer, but this detail is beyond the scope of this article), and the branch entered when it can't find anyone is the one we're investigating
- looks inside factory cache for an AnimationRenderer bound to the DOMRenderer just created (delegate DomRendererFactory2 is capable of returning cached DOMRenderer too, so the "created" could be a reference to an already existing one, thus giving a match in strict equality lookup used by JS for Map.prototype)
- if not found, creates a new one (injected with freshly created renderer delegate, and with a cache cleanup callback to be issued on renderer destruction), adds that to cache and returns it
Little work for BaseAnimationRenderer
Looking at BaseAnimationRenderer code we notice how for most of its implementations it merely calls the corresponding delegated renderer methods, aside from elements insertions and deletions related ones.
export class BaseAnimationRenderer implements Renderer2 {
constructor(
protected namespaceId: string, public delegate: Renderer2, public engine: AnimationEngine,
private _onDestroy?: () => void)
...
appendChild(parent: any, newChild: any): void {
this.delegate.appendChild(parent, newChild);
this.engine.onInsert(this.namespaceId, newChild, parent, false);
}
insertBefore(parent: any, newChild: any, refChild: any, isMove: boolean = true): void {
this.delegate.insertBefore(parent, newChild, refChild);
// If `isMove` true than we should animate this insert.
this.engine.onInsert(this.namespaceId, newChild, parent, isMove);
}
removeChild(parent: any, oldChild: any, isHostElement: boolean): void {
this.engine.onRemove(this.namespaceId, oldChild, this.delegate, isHostElement);
}
This seems to be needed by deep layers of animation module to mark the elements as removed or inserted even in absence of a visible transition, mainly for move operations.
More interesting are the overrides of its child class AnimationRenderer, instanciated by the factory when the component that's gonna be created declares some animation triggers.
Both trigger registrations and added functionalities will be covered in following articles.
Thanks for reading, and feel free to get in touch in comments for every question, correction, clarification or opinion.
Top comments (2)
This is a really interesting read! I'd love to read more about the inner workings and what goes on behind the scenes of Angular animations 😀
Hi William, and thanks for your message.
My beginnings with Angular animations has been highly influenced by your article from a couple years ago, that still today remains one of the most complete resource about the topic.
Next article will be about triggers registering.
Stay tuned. 😉