DEV Community

Adam
Adam

Posted on

Angular: Better Loading Indicator Directive With CDK

In my last post I demonstrated how to make an Angular loading indicator without the CDK. In this post I will show you how to make an loading indicator using CDK that does not rely on observables and it supports promises and resources as well as observables.

Loading Component

Fist lets create our component that will simply contain the backdrop and indicator

loader-component.ts

import { Component, ChangeDetectionStrategy } from '@angular/core';

@Component({
    selector: 'app-loader',
    templateUrl: './loader-component.html',
    styleUrls: ['./loader-component.scss'],
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class LoaderComponent {}
Enter fullscreen mode Exit fullscreen mode

loader-component.html

<div class="backdrop">
    <div class="lds-ripple">
        <div></div>
        <div></div>
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

loader-component.scss

  .backdrop {
      width: 100%;
      height: 100%;
      display: flex;
      align-items: center;
      justify-content: center;
      position: absolute;
      background: rgba(160,160, 160, 0.3);
  }

  .lds-ripple {
    display: inline-block;
    position: relative;
    width: 80px;
    height: 80px;
  }

  .lds-ripple div {
    position: absolute;
    border: 4px solid black;
    opacity: 1;
    border-radius: 50%;
    animation: lds-ripple 1s cubic-bezier(0, 0.2, 0.8, 1) infinite;
  }

  .lds-ripple div:nth-child(2) {
    animation-delay: -0.5s;
  }

  @keyframes lds-ripple {
    0% {
        top: 36px;
        left: 36px;
        width: 0;
        height: 0;
        opacity: 1;
    }

    100% {
        top: 0;
        left: 0;
        width: 72px;
        height: 72px;
        opacity: 0;
    }
  }
Enter fullscreen mode Exit fullscreen mode

Loader

Now you need to create a directive that will accept a loader parameter that is either an Observable, Promise, or Resource and then it needs to display an overlay with the LoaderComponent in it that is positioned over the host element when it becomes visible

loader.ts

import { Directive, input, effect, Resource, inject, ElementRef, OnInit, signal, ComponentRef, OnDestroy } from '@angular/core';
import { Observable, Subscription } from 'rxjs';
import { OverlayRef, Overlay } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import { LoaderComponent } from "./loader-component/loader-component";

@Directive({
    selector: '[loader]'
})
export class Loader implements OnInit, OnDestroy {
    loader = input<Observable<any> | Promise<any> | Resource<any> | null>(null);
    observer: IntersectionObserver | null = null;

    private host = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
    private subscription: Subscription | null = null;
    private isVisible = signal(false);
    private overlay = inject(Overlay);
    private overlayRef: OverlayRef | null = null;

    private isAsync() {
        return this.loader() instanceof Observable || this.loader() instanceof Promise;
    }

    constructor() {
        effect(() => {
            const loader = this.loader();
            const isVisible = this.isVisible();
            this.subscription?.unsubscribe();

            if (loader) { 
                if (isVisible) {
                    if (!this.isAsync()) {
                        let resource = loader as Resource<any>;

                        if (resource.isLoading()) {
                            this.showLoader();
                        }
                        else {
                            this.hideLoader();
                        }
                    }
                    else if (loader instanceof Observable && !this.subscritpion) {
                        this.showLoader();
                        this.subscription = loader.subscribe({
                            next: () => this.hideLoader(),
                            error: () => this.hideLoader(),
                            complete: () => this.hideLoader()
                        });
                    }
                    else {
                        this.showLoader();
                        (loader as Promise<any>).then(() => this.hideLoader(), () => this.hideLoader());
                    }
                }
                else {
                    this.hideLoader();
                }
            }
            else {
                this.subscription = null;
                this.hideLoader();
            }
        })
    }

    ngOnInit() {
        this.observer = new IntersectionObserver(([entry]) => {
            this.isVisible.set(entry.isIntersecting || entry.target.checkVisibility()); // checkVisivibility for when scrolled out of view but still visible
        }, {
            root: null,
            rootMargin: '0px',
            threshold: 0.1
        });

        this.observer.observe(this.host);
    }

    ngOnDestroy() {
        this.hideLoader();
        this.observer?.disconnect();
        this.observer = null;
    }

    private showLoader() {
        if (!this.overlayRef) { 
            const positionStrategy = this.overlay
                .position()
                .flexibleConnectedTo(this.host)
                .withPositions([{ originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'top' }])
                .withFlexibleDimensions(false) 
                .withPush(false)               
                .withViewportMargin(8);

            this.overlayRef = this.overlay.create({
                positionStrategy,
                scrollStrategy: this.overlay.scrollStrategies.reposition(),
                width: this.host.offsetWidth,
                height: this.host.offsetHeight,
            });

            this.overlayRef.attach(new ComponentPortal(LoaderComponent));
        }
    }

    private hideLoader() {
        this.subscription?.unsubscribe();
        this.subscription = null;
        this.overlayRef?.dispose();
        this.overlayRef = null;
    }
}
Enter fullscreen mode Exit fullscreen mode

The constructor creates an effect that will run any time that the value of the loader changes or when the visibility of the element changes. Also if the loader is a Resource it will run every time it's isLoading signal changes. It also unsubscribes for the observer subscription if there is one to make sure you don't have a rogue subscription incase loader's value changed. If loader is null it will call hideLoader to ensure that the loader is hidden incase it was shown when the value changed to null

In the effect it checks to see if the host element is visible an if it is not then hideLoader is called. If it is visible will check to see what type it is and execute one of the following actions based off the type.

  • If it is a resource it will show the loader if it's isLoading signal is true and hide the loader when it is false.
  • If it is an observable it will call showLoader and subscribe to it and call hideLoader on next, error, and complete.
  • If it is a promise it will call showLoader. It will call hideLoader when the promise resolves and on error

ngOnInit triggers an IntersectionObserver to check the host element's visibility. It will trigger every time the element's visibility in the view port changes.

showLoader creates an overlay that is positioned at the top left corner of the host element. It also resizes the overlay to the the host element's width and height. Then it creates component portal constructed with LoaderComponent and attaches it to the overlay. You need to have the width and height of your loader component set to 100% so it will stretch over the overlay.

hideLoader will unsubscribe from the observable's subscription if there is one and tears down the overlay.

ngOnDestroy calls hideLoader and tears down the intersection observer.

Cold Observables

A cold observable is an observable that get executed every time it get's subscribes to like the one from Angular's http client or the of rxjs operator. If you pass a cold observable to the Loader directive it will execute twice. To prevent this behavior pipe your observable through shareReplay.

let source = this.http.get<ResponseType>('https://api.your-site.com/your/url').pipe(shareReplay(1));

this.loader = source;

source.subscribe({
    next: (res) => { // ... do stuff ... }
});
Enter fullscreen mode Exit fullscreen mode

Example

Top comments (0)