In a previous post we have layed the foundation for our custom overlay. To recap, we wanted to build a Google Drive-like custom overlay that looks and feels much like the one built for MachineLabs. Let’s have a look at a preview:
In the first part of this series we learned how to use Angular’s CDK to create our very first custom overlay. On top of that we made it configurable, implemented a “handle” to control (e.g. close) an opened overlay and made it possible to share data with the overlay component.
In this post, we’ll pick up from where we left off and implement a few additional features that will take our overlay to the next level. More specifically, we’ll implement keyboard support, image preloading and add animations in order to make our overlay more engaging and provide better feedback. In the end, we’ll complete this post by adding a toolbar component to fully match Google Drive’s look and feel.
Let’s dive right into it!
TABLE OF CONTENTS
Adding keyboard support
Preloading images
Animating the overlay
Adding a toolbar component
Adding keyboard support
Adding keyboard support is easy. All we need to do is to use the @HostListener() decorator. This decorator is a function decorator that accepts an event name as an argument. Let’s use it inside our FilePreviewOverlayComponent to listen to keydown events on the HTML Document, so that we can close the overlay whenever the escape button was pressed.
Closing the dialog from within the overlay component is only possible because the FilePreviewOverlayRef is now available via the DI system. Remember that we created our own custom injector and defined custom injection tokens for the remote control and the data that we want to share with the component.
Let’s have a look at the code:
import { ..., HostListener } from '@angular/core';
// Keycode for ESCAPE
const ESCAPE = 27;
@Component({...})
export class FilePreviewOverlayComponent {
...
// Listen on keydown events on a document level
@HostListener('document:keydown', ['$event']) private handleKeydown(event: KeyboardEvent) {
if (event.keyCode === ESCAPE) {
this.dialogRef.close();
}
}
constructor(public dialogRef: FilePreviewOverlayRef, ...) { }
Using the host listener we decorate a class method that is called on every keydown event. The function itself gets the KeyboardEvent that we can use to check whether it’s the escape key and only then call close() on the dialogRef.
That’s it already for adding keyboard support. Go ahead and try it out. Open a file preview and then press the escape button.
Preloading images
Instant app response is without any doubt the best, but there are cases when our apps won’t be able to deliver the content immediately, e.g. slow internet connection or even latency issues. In those cases it’s extremely important to provide users with feedback and indicate that progress is being made. It’s crucial to let the user know what is happening in contrast to keep them guessing. One of the most common forms of such feedback is a progress indicator. It reduces the user’s uncertainty, perception of time and offers a reason to wait.
Looking at our overlay, we are facing the exact same problems. When we click to preview an image, depending on the internet connection, the image is fetched by the browser and progressively rendered onto the screen. If the connection is really bad it may take a while. Also, it doesn’t really look that nice if we display image data as it is received, resulting in a top-down filling in of the image.
To solve this, we can use a progress indicator. The good thing is we don’t need to write one from scratch because Angular Material already provides a nice set of loading indicators, one of which is the <mat-spinner>. In order to use it, we need to add the MatProgressSpinnerModule from @angular/material to the imports of our application:
import { ..., MatProgressSpinnerModule } from '@angular/material';
@NgModule({
imports: [
...,
MatProgressSpinnerModule
],
...
})
export class AppModule { }
Note that the <mat-spinner> component is an alias for <mat-progress-spinner mode="indeterminate">. As we can see, the progress-spinner supports different modes, determinate and indeterminate.
The difference is that determinate progress indicators are used to indicate how long an operation will take, whereas indeterminate indicators request that the user needs to wait while something finishes. The latter is used when it’s not necessary to indicate how long it will take or to convey a discrete progress. This is perfect for preloading images because we have no idea how long it may take to fetch the image.
Ok, now that we have added the respective module to our imports we can go ahead and update the template of the FilePreviewOverlayComponent as well as the component class:
@Component({
template: `
<div class="overlay-content">
<div class="spinner-wrapper" *ngIf="loading">
<mat-spinner></mat-spinner>
</div>
<img (load)="onLoad($event)" [style.opacity]="loading ? 0 : 1" [src]="image.url">
</div>
`,
...
})
export class FilePreviewOverlayComponent {
loading = false;
...
onLoad(event: Event) {
this.loading = false;
}
}
First off, we introduce a new property loading and initialize it with a meaningful value, e.g. false. This will show our spinner until the image is loaded. Also note that we are using a property binding to set the opacity of the <img> element to 0 when it’s loading and 1 when it’s finished. If we didn’t do this, we’d still see the image being rendered or filled in from top to bottom. This is just a temporary solution that we will replace with a proper solution using Angular’s Animation DSL in just a moment. Last but not least, we define a success callback as a method on our class that is called when the image is loaded. The callback is hooked up in the template via an event binding. In this particular case we are listening for the load event and when fired, we call the onLoad() method.
One more thing to mention is that we needed to wrap the spinner in another element. The reason for this is that we want the spinner to be vertically and horizontally centered. To achieve this we would leverage the CSS transform property to apply a transformation to the spinner element. The problem is that the <mat-spinner> component is animated with CSS transforms meaning every transformation we set on the element is overridden. Therefore we use a container that wraps the spinner, so that we can savely apply transformation and center it on the screen.
Here’s the image preloading in action. To better demonstrate the loading, you can throttle your connection in the “Network” tab of Chrome’s DevTools.
Animating the overlay
With animations we aim to guide users between views so they feel comfortable using the site, draw focused-attention to some parts of our application, increase spacial awareness, indicate if data is being loaded, and probably the most important point - smoothly transition users between states.
The problem with our overlay is that it still pops right into our faces. The backdrop is already animated for us, but having an animation only for the backdrop is not enough. We also want to add a little bit of motion to the overlay component, so that it’s less surprising for the user.
If you are completely new to animations in Angular, please check out our post on the Foundation Concepts or have a look at our Web Animations Deep Dive with Angular.
Let’s start off by importing the BrowserAnimationsModule into our application’s NgModule like this:
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
@NgModule({
imports: [
...,
BrowserAnimationsModule
],
...
})
export class AppModule { }
With this in place, we can go ahead and define our animations with Angular’s Animation DSL and add it to the component via the animations metadata property in the @Component() decorator:
// Reusable animation timings
const ANIMATION_TIMINGS = '400ms cubic-bezier(0.25, 0.8, 0.25, 1)';
@Component({
...
animations: [
trigger('fade', [
state('fadeOut', style({ opacity: 0 })),
state('fadeIn', style({ opacity: 1 })),
transition('* => fadeIn', animate(ANIMATION_TIMINGS))
]),
trigger('slideContent', [
state('void', style({ transform: 'translate3d(0, 25%, 0) scale(0.9)', opacity: 0 })),
state('enter', style({ transform: 'none', opacity: 1 })),
state('leave', style({ transform: 'translate3d(0, 25%, 0)', opacity: 0 })),
transition('* => *', animate(ANIMATION_TIMINGS)),
])
]
})
export class FilePreviewOverlayComponent {
...
}
As you can see we created two animations, one for fading in the image (fade) and the other one to slide up the content (slideContent). The fade animation will mostly be visible in combination with a spinner. Remember how we used the property binding to temporarily make the image invisible while loading? With the fade animation we can now replace our temporary solution with proper one that leverages the animation DSL.
Next, we define an animationState property that represents the current animation state, e.g. void, enter or leave. By default it’s set to enter that will cause the content of the file preview to always slide up when it’s opened.
@Component({...})
export class FilePreviewOverlayComponent {
...
animationState: 'void' | 'enter' | 'leave' = 'enter';
}
Now we can connect the pieces and set it up in the template:
@Component({
template: `
<div class="overlay-content" [@slideContent]="animationState">
<div class="spinner-wrapper" *ngIf="loading">
<mat-spinner></mat-spinner>
</div>
<img [@fade]="loading ? 'fadeOut' : 'fadeIn'" (load)="onLoad($event)" [src]="image.url">
</div>
`,
...
})
export class F