Animating a custom Angular CDK slide-over dialog

Animating a custom Angular CDK slide-over dialog
Photo by Matthew Brodeur / Unsplash

This is part 2 of a three-part series.

  1. Building a custom slide-over with the Angular CDK Dialog
  2. Animating a custom Angular CDK slide-over dialog
  3. Customisation options to the Angular CDK slide-over dialog

In the previous article, we built a custom slide-over, using the Angular CDK dialog, to display a registration for to our users.

This time, we are going to expand the capabilities of our slide-over component by animating it when it is activated or deactivated.

Overview

The approach we are going to take is heavily derived from our referenced article as well as from the Angular Material dialog implementation. That is, we are going to utilise @angular/animations to enable our component to slide-in and -out of the page.

This requires us to define animation states that we bind to our slide-over component depending on its visibility state.

  • void (initial state)
  • open
  • closed

An animation trigger and transitions from one animation state to another are also needed to be defined.

Implementation

We start by creating an animation metadata where we describe the trigger, which we will name slideInOut, the animation states with associated styles and the transitions with corresponding timings.

export const slideOverAnimation: {
  readonly slideInOut: AnimationTriggerMetadata;
} = {
  slideInOut: trigger('slideInOut', [
    state('void', style({ transform: 'translateX(100%)' })),
    state('open', style({ transform: 'translateX(0)' })),
    state('closed', style({ transform: 'translateX(100%)' })),
    transition('void => open', [animate('500ms ease-in-out')]),
    transition('open => closed', [animate('300ms ease-in-out')]),
  ]),
};

ui-slide-over-animation.ts

This metadata will then be referenced in a new container component that will host not only the dialog content but also the animation definition, state bindings (which match the animation state declarations) and event handlers which will be laid out later in the article.

@Component({
  ...
  animations: [slideOverAnimations.slideInOut],
})
export class UiSlideOverAnimatedContainer extends CdkDialogContainer {
  animationState: 'void' | 'open' | 'closed' = 'open';
}

ui-slide-over-animated-container.component.ts

This container component extends the CdkDialogContainer class and can be used to lay out common aspects and styles of a Dialog component. It expects a cdkPortalOutlet inside its host template where the Dialog's content is to be displayed.

@Component({
  ...
  animations: [slideOverAnimations.slideInOut],
  template: `<ng-template cdkPortalOutlet></ng-template>`
})
export class UiSlideOverAnimatedContainer extends CdkDialogContainer {
  animationState: 'void' | 'open' | 'closed' = 'open';
}

ui-slide-over-animated-container.component.ts

We use @HostBinding to bind the container component to the animation metadata.

@HostBinding('@slideInOut')
animationState: 'void' | 'open' | 'closed' = 'open';

Then, we specify this new component in the DialogConfig's container property.

this.#dialog.open(UiSlideOverAnimatedContent, {
  container: UiSlideOverAnimatedContainer
}

That completes our initial setup. Our animation is now bound to the slide-over component enabling it to slide-in to the page upon activation.

ℹ️
Having some trouble with the animations? Ensure that the animations module is imported in order for the animations to work:

bootstrapApplication(App, { providers: [provideAnimations()] });

Notice, however, that when the slide-over is closed, the expected slide-out animation does not work.

That's because, by default, the slide-over component, and by extension, the CDK dialog, gets destroyed on close. This prevents the exit animation from executing. We need to suppress this standard behaviour in order to orchestrate the exit animation more effectively.

The idea is to give time for the slide-over to animate through the animation states: open => close before destroying it.

To achieve this, we will implement the following:

  • Provide a service that enables customisation of a Dialog's dismissal and destruction process.
  • Disable the default close behaviour via the DialogConfig properties.
  • Update the slide-over content component so it utilises the new service' close method instead of the original one.

Custom Slide-over ref

The custom slide-over ref is a wrapper class that encapsulates a DialogRef and implements a closemethod where the slide-over's exit animation and eventual clean-up are configured. This is also where the original DialogRef.close method can be called.

@Injectable({ providedIn: 'root' })
export class UiSlideOverRef {
  constructor(
    private readonly dialogRef: DialogRef<unknown, UiSlideOverAnimatedContent>
  ) {}

  close(result?: unknown) {
    const containerInstance = this.dialogRef
      .containerInstance as UiSlideOverAnimatedContainer;

    // call DialogRef's close() after exit animation
    // this.dialoRef.close(result);

    containerInstance.startExitAnimation();
  }
}

Dialog config

With the custom slide-over ref defined, we need to be able to provide it to the Dialog context so we can use the custom close method to dismiss our slide-over component.

The DialogConfig object exposes a providers property that can take in a factory function to create services and configuration to a Dialog. This function accepts three arguments, one of which is the DialogRef that we can use to instantiate the custom slide-over ref.

let uiSlideOverRef: UiSlideOverRef;
const dialogRef = this.#dialog.open(UiSlideOverAnimatedContent, {
  ...
  providers: (dialogRef, config, container) => {
    uiSlideOverRef = new UiSlideOverRef(dialogRef);
    return [
      {
        provide: UiSlideOverRef,
        useValue: uiSlideOverRef,
      },
    ];
  },
  closeOnOverlayDetachments: false,
  disableClose: true,
});

In addition to that, a couple more properties are also set, closeOnOverlayDetachments and disableClose, to suppress the default Dialog.close behaviour.

Slide-over content

When provided, the custom slide-over ref can then be injected into the content component, replacing the original DialogRef.

export class UiSlideOverAnimatedContent {
  // #dialogRef = inject(DialogRef);
  
  #dialogRef = inject(UiSlideOverRef);

  ...

  cancel() {
    this.#dialogRef.close();
  }

  save(registrationForm: NgForm) {
    if (registrationForm.invalid) {
      return;
    }
    this.#dialogRef.close(registrationForm.value);
  }
}

At this point, the exit animation is now wired up to our slide-over. Is that it?

Not quite. While the exit animation executes as expected now, the backdrop doesn't get removed from the view. We still have a few more things to set up.

Dialog clean-up

Along with the exit animation, we must also configure the cleaning up of the slide-over and its backdrop. To do this, an EventEmitter can be used from the container component that emits an AnimationEvent whenever the component's animation state changes. These changes propagate from the start and done events of the animation trigger, so we will bind to them via event handlers decorated with @HostListener.

import { AnimationEvent } from '@angular/animations';

animationStateChanged = new EventEmitter<AnimationEvent>();

@HostListener('@slideInOut.start', ['$event'])
onAnimationStart($event: AnimationEvent) {
  this.animationStateChanged.emit($event);
}

@HostListener('@slideInOut.done', ['$event'])
onAnimationDone($event: AnimationEvent) {
  this.animationStateChanged.emit($event);
}

Inside the slide-over ref's close method, the animationStateChanged is being subscribed to, where we orchestrate the timing of the backdrop's removal as well as the dialog's dismissal.

@Injectable({ providedIn: 'root' })
export class UiSlideOverRef {
  constructor(
    private readonly dialogRef: DialogRef<unknown, UiSlideOverAnimatedContent>
  ) {
    this.dialogRef.backdropClick.pipe(take(1)).subscribe(() => this.close());
  }
  
  close(dialogResult?: unknown) {
    ...
    containerInstance.animationStateChanged
      .pipe(
        filter((animationEvent) => animationEvent.phaseName === 'start'),
        take(1)
      )
      .subscribe(() => {
        this.dialogRef.overlayRef.detachBackdrop();
      });
    
    containerInstance.animationStateChanged
      .pipe(
        filter(
          (animationEvent) =>
            animationEvent.phaseName === 'done' &&
            animationEvent.toState === 'closed'
        ),
        take(1)
      )
      .subscribe(() => {
        this.dialogRef.close(result);
      });
    ...
  }
}

ui-slide-over-ref.ts

ℹ️
Note the usage of the take rxjs operator which ensures the subscriptions are completed appropriately, avoiding memory leaks.

The code fragment above shows that only after the slide-over component has completely slid-out and the backdrop cleaned up will we call the original DialogRef's close.

Additionally, the close on backdrop click functionality has to be restored as well, after disabling it in previous steps, so we create a subscription to it inside the slide-over ref's constructor and hook it up to the custom slide-over close method.

That's it! Our slide-over is now fully animated. We were able to leverge @angular/animations to quite conveniently add some dynamic movements to our slide-over component.

Reference

This post is heavily inspired by this article, Custom Overlays with Angular's CDK by Dominic Elm. This gave me clarity as to the power of the Angular CDK. Also being referred to constantly is the Dialog implementation of Angular material.

Next steps

In the next article, we will explore what customisation options we can apply to our slide-over component. See you then.

Lhar Gil

Lhar Gil

Tech-savvy software developer and street photography enthusiast. Exploring the world through code and candid shots of daily life. 📸 All opinions are my own.
England