How to animate akin to :enter and :leave when object reference changes

Viewed 361

Here is some working code:

Stackblitz: https://stackblitz.com/edit/angular-ivy-tt9vjd?file=src/app/app.component.ts

app.component.html

<button (click)='swap()'>Swap object</button>
<div @div *ngIf='object'>{{ object.data }}</div>

app.component.css

div {
  width: 100px;
  height: 100px;
  background: purple;
  display: flex;
  align-items: center;
  justify-content: center;
  color: antiquewhite;
}

app.component.ts

import { animate, style, transition, trigger } from "@angular/animations";
import { Component } from "@angular/core";
import { interval } from "rxjs";
import { first } from "rxjs/operators";

@Component({
  selector: "my-app",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
  animations: [
    trigger("div", [
      transition(":enter", [
        style({ transform: "scale(0)" }),
        animate("250ms 0ms ease-out", style({ transform: "scale(1)" }))
      ]),
      transition(":leave", [
        style({ transform: "scale(1)" }),
        animate("250ms 0ms ease-in", style({ transform: "scale(0)" }))
      ])
    ])
  ]
})
export class AppComponent {
  object: any = { data: "DIV_1" };

  swap() {
    this.object = null;
    interval(0)
      .pipe(first())
      .subscribe(() => {
        this.object = { data: "DIV_2" };
      });

    // DESIRED CODE INSTEAD OF THE ABOVE
    // this.object = { data: "DIV_2" };
  }
}

The issue with this code is that an intermediary null state had to be introduced. Thus I am mixing presentation code with logic in order "to make it work". This is violating good encapsulation practices and adds unnecessary complexity to the code.

How can the same results be achieved using code exclusively in the animations property in the decorator?

requirements

  • Detect a change in the object reference.
  • React to the object reference change akin to how :enter and :leave work; First animate DIV_1 out, then animate DIV_2 in.
  • Encapsulate anything regarding the animation in the animations code. So the swap function should be: swap(){this.object = { data: "DIV_2" };}
2 Answers

So, I spent a bit to solve, but I found a possible solution that can help you. You should create a custom directive to use instead of the classic ngIf.

import {
  Directive,
  Input,
  TemplateRef,
  ViewContainerRef
} from '@angular/core';

@Directive({
  selector: '[ngIfAnimation]'
})
export class NgIfAnimationDirective {
  private value: any;
  private hasView = false;

  constructor(
    private view: ViewContainerRef,
    private tmpl: TemplateRef<any>
  ) { }

  @Input() set ngIfAnimation(val: any) {
    if (!this.hasView) {
      this.view.createEmbeddedView(this.tmpl);
      this.hasView = true;
    } else if (val !== this.value) {
      this.view.clear();
      this.view.createEmbeddedView(this.tmpl);
      this.value = val;
    }
  }
}

Quickly: we clear the current view, instantiate an embedded view and inserts it into the div each time there's a change. About your component your animations will be like this (you can change them of course):

animations: [
    trigger('div', [
      state('void', style({ transform: 'scale(0)' })),
      state('*', style({transform: 'scale(1)' })),
      transition('void => *', [animate('0.2s 0.2s ease-in')]),
      transition('* => void', [animate('0.2s ease-in')])
    ])
  ],

And your template:

<div [@div] *ngIfAnimation="object">{{ object.data }}</div>

Remember to remove your *ngIf because you can't have multiple template bindings on one element.

Stackblitz: https://stackblitz.com/edit/angular-ivy-hc8snt?file=src/app/app.component.html

Just wanted to share what I ended up with. The solution is mostly inspired by Francesco Lisandro's answer.

  • A structural directive is created
  • The directive takes an object with 2 properties
    • data: The object of which the reference changes
    • leaveTransitionDuration: A duration that delays the insertion of an empty state
  • The directive inserts a void state after the old object and before the new object is created. The void state is inserted after the leaveTransitionDuration.
  • The directive does not apply the leaveTransitionDuration when the reference changes from a falsy value to an object. So the regular :enter animation is not delayed.

app.component.html

<div @div *ngIfAnimation="{data: object, leaveTransitionDuration: 2000}">{{ object.data }}</div>

app.component.ts

import { Component } from "@angular/core";
import { animate, style, transition, trigger } from "@angular/animations";

@Component({
  selector: "my-app",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
  animations: [
    trigger("div", [
      transition(":enter", [
        style({ transform: "scale(0)" }),
        animate("500ms 0s ease-in", style({ transform: "scale(1)" }))
      ]),
      transition(":leave", [
        style({ transform: "scale(1)" }),
        animate("2s ease-in", style({ transform: "scale(0)" }))
      ])
    ])
  ]
})
export class AppComponent {
  object: any = { data: "DIV_1" };

  swap() {
    this.object = { data: "DIV_2" };
  }
}

ngIfAnimation.diretive.ts

import { Directive, Input, TemplateRef, ViewContainerRef } from "@angular/core";
import { interval } from "rxjs";
import { first } from "rxjs/operators";

@Directive({
  selector: "[ngIfAnimation]"
})
export class NgIfAnimationDirective {
  constructor(private view: ViewContainerRef, private tmpl: TemplateRef<any>) {}
  private old_data = null;
  private leaveTransitionDuration: number;

  @Input() set ngIfAnimation(object: {
    data: any;
    leaveTransitionDuration: number;
  }) {
    this.leaveTransitionDuration = object.leaveTransitionDuration;
    this.view.clear();
    if (object.data) this.add_new_view();
    this.old_data = object.data;
  }

  private get delay(): number {
    return this.old_data ? this.leaveTransitionDuration : 0;
  }
  private add_new_view(): void {
    interval(this.delay) // timeout until `:leave` animation is ran
      .pipe(first())
      .subscribe(() => {
        this.view.createEmbeddedView(this.tmpl);
      });
  }
}

Check out the Stackblitz for a demo: https://stackblitz.com/edit/angular-ivy-t7ea53?file=src/app/ngIfAnimation.directive.ts

Note: Make sure the leaveTransitionDuration equals the duration of the :leave transition.

Related