Here I will be demonstrating how to create loader directive that has a back drop and an animated loading indicator that can be placed on any element and that does not use the CDK overlay.
Problems With CDK Overlay
The back drop is placed over the whole page even though you have it positioned over an element with scrolling set to reposition. It will also show the overlay and attach it to the body element if the element is hidden and put the loader in the top left corner of the page when it is set to be in the center. If the element is hidden you would not want the loader to be displayed until the element is shown.
Loader Component
First create a loader component that simply contains the loading indicator
loader.compponent.ts
@Component({
selector: 'app-loader',
templateUrl: './loader.component.html',
styleUrls: ['./loader.component.scss']
})
export class LoaderComponent { }
loader.component.scss
.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.component.html
<div class="lds-ripple">
<div></div>
<div></div>
</div>
Loader Directive
Now you need to make the directive. It will take an observable and display the loading component whenever the the input value changes and hide it when the observable resolves.
First lets create the directive that simply displays the loader.
loader.directive.ts
@Directive({
selector: '[loader]',
import: [LoaderComponent]
})
export class LoaderDirective implements OnDestroy {
private elmRef = inject(ElementRef);
private appRef = inject(ApplicationRef);
private subscription: Subscription | null = null;
private backdrop: HTMLElement | null = null;
private loaderRef: ComponentRef<LoaderComponent> | null = null;
loader = input<Observable<any> | null>(null);
private getRelativeContainer() {
let elm: HTMLElement | null = this.elmRef.nativeElement;
let style: CSSStyleDeclaration | null = elm ? window.getComputedStyle(elm) : null;
while (style && style.position !== "relative" && elm) {
elm = elm.parentElement;
style = elm ? window.getComputedStyle(elm) : null;
}
return elm;
}
private showLoader() {
this.backdrop = document.createElement("div");
this.backdrop.style.display = "flex";
this.backdrop.style.alignItems = "center";
this.backdrop.style.justifyContent = "center";
this.backdrop.style.position = "absolute";
this.backdrop.style.height = this.elmRef.nativeElement.offsetHeight + "px";
this.backdrop.style.width = this.elmRef.nativeElement.offsetWidth + "px";
this.backdrop.style.backgroundColor = "rgba(50, 50, 50, 0.1)";
let relElm = this.getRelativeContainer();
if (relElm) {
this.backdrop.style.top = relElm === this.elmRef.nativeElement ? "0px" : relElm.offsetTop + "px";
this.backdrop.style.left = relElm === this.elmRef.nativeElement ? "0px" : relElm.offsetLeft + "px";
}
else {
let eRct = this.elmRef.nativeElement.getBoundingClientRect();
this.backdrop.style.top = eRct.top + "px";
this.backdrop.style.left = eRct.left + "px";
}
let loader = document.createElement("app-loader");
this.loaderRef = createComponent(LoaderComponent, {
environmentInjector: this.appRef.injector,
hostElement: loader
});
this.backdrop.appendChild(loader);
this.elmRef.nativeElement.appendChild(this.backdrop);
}
private hideLoader() {
if (this.loaderRef && this.backdrop) {
this.backdrop.remove();
this.loaderRef.destroy();
this.loaderRef = null;
this.subscription = null;
}
}
constructor() {
effect(() => {
this.subscription?.unsubscribe();
if (this.loader()) {
this.showLoader();
this.subscription = this.loader()?.subscribe({
next: () => this.hideLoader(),
complete: () => this.hideLoader(),
error: () => this.hideLoader()
})
}
else {
this.subscription = null;
this.hideLoader();
}
});
}
ngOnDestroy() {
this.subscription?.unsubscribe();
this.hideLoader();
}
}
The constructor uses effect to execute every time the loader input's value is changed. It will unsubscribe from the from the subscription if one exists. If loader is null it will set the subscription to null then make a call to hideLoader() in case the value was changed to null before the previous observable is resolved. If loader's value is an observable the loader is displayed and it subscribes to the observable with an observer that hides the loader when the observable resolves.
As you can see the showLoader() function creates a backdrop and styles it with it's positioning based on whether or not the host element for the directive is contained in a relatively positioned container. The width and the height are set to stretch over the host element entirely. Then it uses createComponent to dynamically create a loader component and attach it to the backdrop. Finally it attaches the backdrop to the host element.
hideLoader() removes the backdrop from the host element and destroys the component that was dynamically created in showLoader(). Then it unsets the subscription and the component ref.
This directive implements OnDestroy incase the loader observable has not resolved at the time that the directive gets destroyed.
Problem Hidden Element
If the host element is hidden when the loader's value is changed the backdrop will have a width and height of 0. So you will have to add functionality to the directive that will wait until the host element becomes visible and at the same time not display the loader if the observable has resolved at the time that the host element becomes visible. In order to do this additional observables and subscriptions are needed.
loader.directive.ts
@Directive({
selector: '[loader]',
import: [LoaderComponent]
})
export class LoaderDirective implements OnDestroy {
private elmRef = inject(ElementRef);
private appRef = inject(ApplicationRef);
private subscription: Subscription | undefined | null = undefined;
private backdrop: HTMLElement | null = null;
private loaderRef: ComponentRef<LoaderComponent> | null = null;
private visible = new Subject<void>();
private visSubscription: Subscription | null = null;
private intervalSubscription: Subscription | null = null;
private loaded = new Subject<void>();
loader = input<Observable<any> | null>(null);
private getRelativeContainer() {
let elm: HTMLElement | null = this.elmRef.nativeElement;
let style: CSSStyleDeclaration | null = elm ? window.getComputedStyle(elm) : null;
while (style && style.position !== "relative" && elm) {
elm = elm.parentElement;
style = elm ? window.getComputedStyle(elm) : null;
}
return elm;
}
private checkVisibilitty() {
this.intervalSubscription?.unsubscribe()
this.intervalSubscription = interval(100).subscribe(() => {
if (this.elmRef.nativeElement.checkVisibility()) {
this.visible.next();
this.intervalSubscription?.unsubscribe();
}
});
}
private showLoader() {
this.visSubscription?.unsubscribe();
this.checkVisibilitty();
this.visSubscription = this.visible.pipe(takeUntil(this.loaded)).subscribe(() => {
this.backdrop = document.createElement("div");
this.backdrop.style.display = "flex";
this.backdrop.style.alignItems = "center";
this.backdrop.style.justifyContent = "center";
this.backdrop.style.position = "absolute";
this.backdrop.style.height = this.elmRef.nativeElement.offsetHeight + "px";
this.backdrop.style.width = this.elmRef.nativeElement.offsetWidth + "px";
this.backdrop.style.backgroundColor = "rgba(50, 50, 50, 0.1)";
let relElm = this.getRelativeContainer();
if (relElm) {
this.backdrop.style.top = relElm === this.elmRef.nativeElement ? "0px" : relElm.offsetTop + "px";
this.backdrop.style.left = relElm === this.elmRef.nativeElement ? "0px" : relElm.offsetLeft + "px";
}
else {
let eRct = this.elmRef.nativeElement.getBoundingClientRect();
this.backdrop.style.top = eRct.top + "px";
this.backdrop.style.left = eRct.left + "px";
}
let loader = document.createElement("app-loader");
this.loaderRef = createComponent(LoaderComponent, {
environmentInjector: this.appRef.injector,
hostElement: loader
});
this.backdrop.appendChild(loader);
this.elmRef.nativeElement.appendChild(this.backdrop);
});
}
private hideLoader() {
this.loaded.next();
if (this.loaderRef && this.backdrop) {
this.backdrop.remove();
this.loaderRef.destroy();
this.loaderRef = null;
this.subscription = null;
}
}
constructor() {
effect(() => {
this.subscription?.unsubscribe();
if (this.loader()) {
this.showLoader();
this.subscription = this.loader()?.subscribe({
next: () => this.hideLoader(),
complete: () => this.hideLoader(),
error: () => this.hideLoader()
})
}
else {
this.subscription = null;
this.hideLoader();
}
});
}
ngOnDestroy() {
this.subscription?.unsubscribe();
this.visSubscription?.unsubscribe();
this.intervalSubscription?.unsubscribe();
this.hideLoader();
}
}
The checkVisibility() method will unsubscribe from the previous interval if one exists. This will insure that you don't have a rouge subscription when the loader value changes. Then starts up an interval that runs every 100ms checking the visibility of the host element. When it becomes visible the method will resolve the visible observable and cancel the interval.
hideLoader() has been modified to resolve the the loaded observable one time to indicate that the loader observable has resolved.
The showLoader() method has been modified to unsubscribe from the previous visSubscription and to take the visible observable until the the loader/loaded observable has resolved. This will ensure that the loader is not displayed after they have been resolved. And the rest of the logic to the method is preformed after the host element becomes visible.
Http Observables
If you pass and observable from HTTPClient it will make 2 calls to your API URL because it is a cold observable. That means that the logic to the observable will only execute upon subscribing. You need to convert it to a hot observable in order to prevent recurring requests . In order to do that you need to pipe the HTTPClient observable to shareReplay. shareReplay shares a replay observable with the last x amount of values.
Example
...
this.loaderDirectiveValue = this.http.get<APIURLReturnType>('/your/api/url').pipe(shareReplay());
this.loaderDirectiveValue.pipe(take(1)).subscribe({
next: data => {
// logic for success
}
});
...
Top comments (0)