Writing a simple “busy” spinner Directive in Angular-Material
As a front-end developer in the world of async service calls it’s important to give feedback to your user. However, it’s a pain to do this on a case-by-case basis through a whole lot of *ngIf
and switching based upon some sort of boolean flag. Let’s write a structural directive to handle this for us with as little work as possible.
The structural directive we’re about to write is going to take a boolean expression, and swap the contents of the element it’s attached to. When the expression is true it will swap in a MatProgressSpinner
component and when it’s false we’ll display what the user has coded into their component.
Here’s a demo:
Keep it DRY
The importance of this isn’t simply to find a way to provide the user with visible feedback, but also to do it in a consistent manner that conforms to the DRY principle where we don’t repeat code by using copy/paste all over the place.
DRY: Don’t repeat yourself
One way to implement the equivalent of this without conforming to DRY is simply:
<div *ngIf="isLoading"><mat-spinner [diameter]="18"></mat-spinner></div>
<div *ngIf="!isLoading">
<entity-details></entity-details>
</div>
However, in order to reuse this, you’d have to copy/paste that spinner code. Even if simplified that into it’s own component we’d end up with only slightly less copy/paste with the 2 opposing *ngIf
statements. However, if we do it in a structural directive we can use the equivalent of writing a single *ngIf
statement to accomplish the same goal.
So, how’s it work?
You can get the full source from the StackBlitz above within the spin-on-directive.ts
with a small styling tidbit in styles.scss
but the important bit of the directive code looks like this:
@Input() set appSpinOn(condition: boolean) {
if (!!condition !== this.#isSpinning) {
this.#spinner = null;
this.viewContainer.clear();
this.#isSpinning = condition;
if (!condition) {
// Render the template
this.viewContainer.createEmbeddedView(this.templateRef);
} else if (condition) {
this.addSpinner();
}
}
}private addSpinner() {
const componentFactory = this.componentFactoryResolver.resolveComponentFactory(MatProgressSpinner);
const { instance } = this.viewContainer.createComponent<MatProgressSpinner>(componentFactory);
instance.diameter = this.#diameter;
instance.color = this.#color;
instance.mode = 'indeterminate';
instance._elementRef.nativeElement.classList.add('spin-on-instance');
this.#spinner = instance;
}
}
The setter for the appSpinOn
input value takes the expression evaluating to a boolean. If the expression has evaluated before to the same result, it won’t do anything so it just leaves what’s in the dom. If it hasn’t evaluated, or the value is different, it will determine whether the condition is true or false. Here we use this.viewContainer.createEmbeddedView(this.templateRef);
to write the templateRef that the directive is attached to if the condition is false
.
However, if the condition is true
things get a little more complicated. We use dynamic component creation to create the MatProgressSpinner
within the ViewContainerRef
that Angular injects to the directive, and then configure it for styling, and then
We’ll also need to add some styling, otherwise we won’t be able to use it in place for subbing out things like icons. I put the following in the root styles.scss file which allows for appropriate alignment and margins to work in place of an icon:
.mat-progress-spinner.spin-on-instance {
vertical-align: middle;
display: inline-block;
margin: 3px;
}
From there, using it is as simple as adding SpinOnModule
the imports of any module using it and then using it in code.
One way of using it is to sub out content. For example:
<div>
<entity-details *appSpinOn="isLoading"></entity-details>
</div>
The defaults are useful for subbing out icons. For example, the demo uses the following on a “Save” button:
<button mat-raised-button [disabled]="isSaving || isLoading" (click)="save()">
<mat-icon *appSpinOn="isSaving">save</mat-icon> Save
</button>
Before saving, the image has a “save” icon. The directive substitutes just the icon with the spinner.
We can also use the variables color
which takes an angular-material ThemePalette
string and a numeric diameter
to change the size the directive uses: *appSpinOn="isLoading; color: 'primary': diameter: 40"
.
What we’ve made here is a useful directive that gives the user feedback when something is going on behind-the-scenes. It reduces the amount of code we have to write, it’s flexible, and configurable.