Angular LAB: animating lists and using AnimationBuilder for imperative animations

michelestieven

Michele Stieven

Posted on October 9, 2024

Angular LAB: animating lists and using AnimationBuilder for imperative animations

Did you know that Angular includes a complex animation system? I find it especially useful when I want to animate elements when they enter the screen or when they get destroyed!

Also, you can use AnimationBuilder in order to imperatively play, pause or stop some custom animations! Let's see how it's done.

Creating a list

In this exercise we start by creating a list, something like this:

@Component({
  selector: 'app-root',
  standalone: true,
  template: `
    <button (click)="addUser()">Add user</button>
    <ul>
    @for (user of users(); track user.id) {
      <li>{{ user.name }}</li>
    }
    </ul>
  `,
})
export class AppComponent {
  users = signal<User[]>([
    { id: Math.random(), name: 'Michele' }
  ]);

  addUser() {
    this.users.update(users => [...users, { id: Math.random(), name: 'New user' }]);
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice that we've included a button which adds a user to the list!

Animating the list

Now, what if we want to animate the new user which will be added? First, we want to tell Angular that we want to use its animation system by providing it in your main configuration:

import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';

bootstrapApplication(AppComponent, {
  providers: [
    provideAnimationsAsync(),
  ]
});
Enter fullscreen mode Exit fullscreen mode

Then, we can create our animation:

import { trigger, transition, style, animate } from '@angular/animations';

const fadeInAnimation = trigger('fadeIn', [
  transition(':enter', [
    style({ transform: 'scale(0.5)', opacity: 0 }),
    animate(
      '.3s cubic-bezier(.8, -0.6, 0.2, 1.5)', 
      style({ transform: 'scale(1)', opacity: 1 })
    )
  ])
])
Enter fullscreen mode Exit fullscreen mode

With these helpers we:

  • Created an animation trigger called fadeIn
  • Added a transition when the element enters the screen
  • Applied an initial style
  • Immediately started the animation which will result in a new style applied to the element

For more information on how to write animations, you can refer to the official guide which is awesome!

Now let's apply this animation to each of our elements in the list:

@Component({
  ...,
  template: `
    <button (click)="addUser()">Add user</button>
    <ul>
    @for (user of users(); track user.id) {
      <li @fadeIn>{{ user.name }}</li> <!-- Notice here -->
    }
    </ul>
  `,
  // Also, add the animation to the metadata of the component
  animations: [fadeInAnimation]
}) 
Enter fullscreen mode Exit fullscreen mode

Now, when a new item is added, it gets animated! Our first step is done.

Notice that in order for our animation to work correctly, Angular has to track each element in our for, because otherwise it could end up re-creating the same elements when updating the template, resulting in unwanted animations. This comes for free with the new Control Flow syntax because the track property is mandatory, but if you're using old verisons of Angular with the *ngFor directive, you must use the trackBy option like this:

<li
  *ngFor="let user of users; trackBy: trackByUserId"
  @fadeIn
>{{ user.name }}</li>
Enter fullscreen mode Exit fullscreen mode
// A class method in your component:
trackByUserId(index, user: User) {
  return user.id;
}
Enter fullscreen mode Exit fullscreen mode

Now, let's add another type of animation to our list.

AnimationBuilder

Let's add a button to each element of our list:

<li @fadeIn>
  {{ user.name }}
  <button>Make me blink</button>
</li>
Enter fullscreen mode Exit fullscreen mode

Imagine this: we want to make the element blink when we press the button. That'd be cool! This is where the AnimationBuilder service comes in.

First, let's create a Directive which will be applied to every element. In this directive, we'll inject both ElementRef and AnimationBuilder:

import { AnimationBuilder, style, animate } from '@angular/animations';

@Directive({
  selector: '[blink]',
  exportAs: 'blink', // <--- Notice
  standalone: true
})
export class BlinkDirective {

  private animationBuilder = inject(AnimationBuilder);
  private el = inject(ElementRef);
}
Enter fullscreen mode Exit fullscreen mode

Notice that we exported the directive: we'll get to the reason in a few seconds.

Then, we can create a custom animation like this:

export class BlinkDirective {

  ...

  private animation = this.animationBuilder.build([
    style({ transform: 'scale(1)', opacity: 1 }),
    animate(150, style({ transform: 'scale(1.1)', opacity: .5 })),
    animate(150, style({ transform: 'scale(1)', opacity: 1 }))
  ]);
}
Enter fullscreen mode Exit fullscreen mode

We're using the same functions that we used in the previous animation, just with different styles.

Now we want to create a player which will perform the animation on our element:

export class BlinkDirective {

  ...

  private player = this.animation.create(this.el.nativeElement);
}
Enter fullscreen mode Exit fullscreen mode

And now let's expose a method which will actually start the animation!

export class BlinkDirective {

  ...

  start() {
    this.player.play();
  }
}
Enter fullscreen mode Exit fullscreen mode

There's only one step left: we must import the directive, apply it to our elements, grab it with a template variable and call the method when the button is pressed!

@Component({
  selector: 'app-root',
  standalone: true,
  template: `
    <button (click)="addUser()">Add user</button>
    <ul>
    @for (user of users(); track user.id) {
      <li @fadeIn blink #blinkDir="blink">
        {{ user.name }}
        <button (click)="blinkDir.start()">Make me blink</button>
      </li>
    }
    </ul>
  `,
  imports: [BlinkDirective],
  animations: [
    fadeInAnimation
  ]
})
Enter fullscreen mode Exit fullscreen mode

We can grab the directive's instance and put it in a local variable because we previously exported the directive with exportAs. That's the key part!

Now, try clicking on the button: the element whould be animated correctly!

The exercise is complete, but this is just the tip of the iceberg! The AnimationPlayer has a lot of commands which you can use to stop, pause and resume the animation. Very cool!

interface AnimationPlayer {
  onDone(fn: () => void): void;
  onStart(fn: () => void): void;
  onDestroy(fn: () => void): void;
  init(): void;
  hasStarted(): boolean;
  play(): void;
  pause(): void;
  restart(): void;
  finish(): void;
  destroy(): void;
  reset(): void;
  setPosition(position: number): void;
  getPosition(): number;
  parentPlayer: AnimationPlayer;
  readonly totalTime: number;
  beforeDestroy?: () => any;
}
Enter fullscreen mode Exit fullscreen mode

Here's our full example if you want to play with it: just put it in your main.ts file and see it in action!

import { Component, signal, Directive, ElementRef, inject } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { trigger, transition, style, animate, AnimationBuilder } from '@angular/animations';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';

interface User {
  id: number;
  name: string;
}

@Directive({
  selector: '[blink]',
  exportAs: 'blink',
  standalone: true
})
export class BlinkDirective {

  private animationBuilder = inject(AnimationBuilder);
  private el = inject(ElementRef);

  private animation = this.animationBuilder.build([
    style({ transform: 'scale(1)', opacity: 1 }),
    animate(150, style({ transform: 'scale(1.1)', opacity: .5 })),
    animate(150, style({ transform: 'scale(1)', opacity: 1 }))
  ]);

  private player = this.animation.create(this.el.nativeElement);

  start() {
    this.player.play();
  }
}

const fadeInAnimation = trigger('fadeIn', [
  transition(':enter', [
    style({ transform: 'scale(0.5)', opacity: 0 }),
    animate(
      '.3s cubic-bezier(.8, -0.6, 0.2, 1.5)', 
      style({ transform: 'scale(1)', opacity: 1 })
    )
  ])
])

@Component({
  selector: 'app-root',
  standalone: true,
  template: `
    <button (click)="addUser()">Add user</button>
    <ul>
    @for (user of users(); track user.id) {
      <li @fadeIn blink #blinkDir="blink">
        {{ user.name }}
        <button (click)="blinkDir.start()">Make me blink</button>
      </li>
    }
    </ul>
  `,
  imports: [BlinkDirective],
  animations: [
    fadeInAnimation
  ]
})
export class App {
  users = signal<User[]>([
    { id: Math.random(), name: 'Michele' }
  ]);

  addUser() {
    this.users.update(users => [...users, { id: Math.random(), name: 'New user' }]);
  }
}

bootstrapApplication(App, {
  providers: [
    provideAnimationsAsync()
  ]
});
Enter fullscreen mode Exit fullscreen mode
💖 💪 🙅 🚩
michelestieven
Michele Stieven

Posted on October 9, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related