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 {}
loader-component.html
<div class="backdrop">
<div class="lds-ripple">
<div></div>
<div></div>
</div>
</div>
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;
}
}
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;
}
}
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 ... }
});
Example
Top comments (0)