Angular transition animations

In Angular, "transition animations" refer to the visual effects applied when an element changes between different states. This is typically accomplished by attaching a trigger to a property that carries the values of the states we wish to animate between. The final portion of this pipeline is shared with "timeline animations", which are driven by directly manipulating Animation Players — a topic that falls outside the scope of this series.

A quick look at Angular renderers

Angular uses renderers to interact with the DOM without working directly against the DOM nodes themselves. These renderers act as an abstraction layer over native DOM operations and are shaped according to the abstract class Renderer2. Renderers play a central role in transition animations, as certain renderer variants are tasked with initiating the animation flow.

Rather than being instantiated manually, renderers are produced by a factory known as RendererFactory2.

The animations module

To begin using animations, the first requirement is importing the BrowserAnimationsModule. Among its responsibilities, this module supplies concrete implementations of the abstract classes mentioned above, effectively adding animation capabilities to the component creation process.

Chief among these is the AnimationRendererFactory, which, together with the set of Renderer implementations it can build, takes the place of those supplied by BrowserModule. For clarity, this series will confine itself to the Browser platform, the environment used for web application development.

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]
  }
];
Enter fullscreen mode Exit fullscreen mode

How AnimationRendererFactory builds an animation renderer

At the core of this factory is an injected AnimationEngine, which the renderers it produces use to layer animated behaviour on top of standard DOM operations.

@Injectable()
export class AnimationRendererFactory implements RendererFactory2 {
  ...
  constructor(
      private delegate: RendererFactory2, private engine: AnimationEngine, private _zone: NgZone)
Enter fullscreen mode Exit fullscreen mode

Another key dependency in its constructor is the delegate property, typed as RendererFactory2 and holding an instance of DomRendererFactory2. This setup embodies the Delegation Pattern, where an enhanced renderer factory is assembled by composing another.

All non-animated DOM work is handed off to the DefaultDomRenderer2 instances produced by the delegate factory, whereas the new delegating animated renderers concern themselves exclusively with animation-related activities.

Since this class functions as a renderer factory, its central logic lives in the createRenderer method. It takes two inputs:

  • hostElement — the topmost ancestor of every element in the template, often the element matched by the component selector.
  • A type parameter, defined as RendererType2, which carries rendering metadata drawn largely from the @Component decorator.

The opening lines of this method reveal how a plain animation renderer is constructed for components that declare no animations.

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;
  }
Enter fullscreen mode Exit fullscreen mode
  1. It first calls on the factory delegate to produce a plain, animation-agnostic DOMRenderer, storing it in a local variable named delegate. Note the naming overlap: the outer this.delegate is the factory delegate, while the inner local delegate refers to the newly minted renderer delegate.
  2. It then inspects the animations field of the @Component decorator for any triggers. This follows a check that the creation request isn't for a hostRenderer, a detail we won't dig into here. When no triggers are found, the branch under examination is taken.
  3. Next, it consults a factory cache for an AnimationRenderer paired with the DOMRenderer just obtained. Since the delegate DomRendererFactory2 can hand back cached DOMRenderer instances, what looks like a fresh creation may actually be a reference to an existing one, allowing a match through the strict equality lookup used by Map.prototype.
  4. If no match exists, a new AnimationRenderer is forged (supplied with the recently created renderer delegate and a cleanup callback for when the renderer is destroyed), added to the cache, and returned.

BaseAnimationRenderer's limited role

Examining BaseAnimationRenderer, it becomes clear that for the majority of its methods, it simply forwards calls to the corresponding delegated renderer. The one area where it deviates involves element insertion and removal.

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);
  }
Enter fullscreen mode Exit fullscreen mode

This behaviour seems designed for the deeper layers of the animation module, which need to flag elements as inserted or removed even when no visible transition occurs, particularly in the context of move operations. The more compelling logic lies in the overrides found in its subclass AnimationRenderer, which the factory instantiates when a component defines animation triggers. Both trigger registration and the extra capabilities offered will be explored in subsequent articles.

Thank you for reading — please feel free to leave any questions, corrections, or thoughts in the comments below.