An Introduction to HTTP Requests & Fetching Data in Ionic
Create a Custom Modal Page Transition Animation in Ionic
Migrating to the Upgraded Ionic Tabs Components
Creating an Achievement Unlocked Animation with Angular Animations in Ionic
Adding Sound Effects to an Ionic Application
Using MongoDB with Ionic and NestJS
Adding JWT Authentication to an Ionic Application with MongoDB and NestJS
Creating & Using a Headless CMS with an Ionic Application
Migrating to the Upgraded Ionic Tabs Components
In this tutorial, we are going to be walking through how to migrate your existing Ionic 4 applications that use tabs to the new tabs components that were introduced in beta.15. We will go through a simple example of converting the old tabs components to the new ones, and we will also discuss the general ideas behind the changes.
It is uncommon to see breaking changes introduced throughout the beta period, but in this case it seems to be something that has been highly sought after and will provide a lot of benefits. Although it will require changes to your application, the required changes are small and easy to make. I intend to go into quite a bit of depth in this article, but the actual changes required are just a few small tweaks to the template containing the tabs.
Tabs in Ionic 4
With the release of Ionic 4 and the move to Angular style navigation, we saw some changes introduced to the way tabs worked. This was mostly in the way that the tabs component would integrate with the Angular routing system.
As with all the other pages in our application, we have to define routes for each of the tabs we want to display. Tabs are a bit of a special case because each tab has its own<ion-router-outlet>
(its own navigation view) instead of using the single main <ion-router-outlet>
. This means that each route for a specific tab needs to be tied to a specific <ion-router-outlet>
through naming those outlets and specifying the matching outlet in the route for that tab using the outlet
property.
This looks something like this:
const routes: Routes =[{
path:'tabs',
component: HomePage,
children:[{
path:'location',
outlet:'location',
component: LocationPage
},{
path:'camp',
outlet:'camp',
component: CampDetailsPage
},{
path:'me',
outlet:'me',
component: MyDetailsPage
}]},{
path:'',
redirectTo:'/tabs/(location:location)'}];
If you would like to learn more about the general approach to creating a tabs layout in Ionic 4, then I would recommend reading: Creating a Tabs Layout with Angular Routing and Ionic 4.
I wanted to provide a bit of a recap of how the new tabs work, but I also want to highlight that this is not changing. You can keep the routes you have defined for your tabs layout exactly the same with the new tabs components.
Updating the Tabs Components
What is changing is the way we add the tabs components to our templates. The general idea behind these changes is to remove the tight integration between the tabs component and the underlying navigation system.
Previously, Ionic just provided a simple tabs component that would handle all the “magic” and display the tabs for you. Now, Ionic is just providing a more generic component that we can hook into whatever navigation system it is that we are using. The end result is a little bit more code, but a lot more flexibility. Tabs are now just this generic component that we can use however we see fit (e.g. you aren’t strictly limited to using tab buttons for a tabbed navigation interface).
A template using the old (pre-beta.15) tabs would look something like this:
<ion-tabs><ion-tablabel="Location"icon="navigate"href="/tabs/(location:location)"><ion-router-outletname="location"></ion-router-outlet></ion-tab><ion-tablabel="My Details"icon="person"href="/tabs/(me:me)"><ion-router-outletname="me"></ion-router-outlet></ion-tab><ion-tablabel="Camp Details"icon="bookmarks"href="/tabs/(camp:camp)"><ion-router-outletname="camp"></ion-router-outlet></ion-tab></ion-tabs>
This is actually an example from one of the applications in my book. Now, let’s take a look at what the template looks like after it has been upgraded to the new tabs:
<ion-tabs><!-- Tabs --><ion-tabtab="location"><ion-router-outletname="location"></ion-router-outlet></ion-tab><ion-tabtab="me"><ion-router-outletname="me"></ion-router-outlet></ion-tab><ion-tabtab="camp"><ion-router-outletname="camp"></ion-router-outlet></ion-tab><!-- Tab Buttons --><ion-tab-barslot="bottom"><ion-tab-buttontab="location"href="/tabs/(location:location)"><ion-iconname="navigate"></ion-icon><ion-label>Location</ion-label></ion-tab-button><ion-tab-buttontab="me"href="/tabs/(me:me)"><ion-iconname="person"></ion-icon><ion-label>My Details</ion-label></ion-tab-button><ion-tab-buttontab="camp"href="/tabs/(camp:camp)"><ion-iconname="bookmarks"></ion-icon><ion-label>Camp Details</ion-label></ion-tab-button></ion-tab-bar></ion-tabs>
There is a bit more code now, but the general concept is still largely the same. The key difference here is that we have now separated the tab buttons out from the tabs themselves. We tie a specific <ion-tab-button>
to a specific <ion-tab>
by adding the tab
attribute to both of them, and giving them the same name.
All of the routing remains the same, we just attach the href
to the associated tab button for a tab (again, instead of having it tied directly to the tab itself).
Summary
For some people, this change might not make much of a difference, but it does make tabs significantly more flexible:
- You can now use
<ion-tab-bar>
and<ion-tab-button>
independently of a tabbed navigation interface - You have more flexibility in determining what the tab buttons look like. You can supply whatever you like inside of
<ion-tab-button>
and since the elements you add will not be inside of a Shadow DOM you can style them directly with CSS. - Since the tabs component is now more generic, it can be more easily integrated with other frameworks/navigation systems
Creating an Achievement Unlocked Animation with Angular Animations in Ionic
I’ve written a few articles in the past that covered using the Angular Animations API in an Ionic application. You can find some of those articles below:
- The Web Animations API in Ionic
- Add to Cart Animation
- Animating from the Void: Enter and Exit Animations in Ionic
Most of the tutorials that I have created so far focus on reasonably simple animations, but the Angular Animations API is powerful and can handle much more complicated animations.
In this tutorial, we will be focusing on implementing an animation sequence to create an “achievement unlocked” overlay in an Ionic application. The end result will look like this:
Unlike previous tutorials, this animation will involve animating child elements of the parent animation. This has some implications for the code we write.
We are going to focus specifically on the animation in this tutorial. We will just be adding everything to the Home page of the application rather than creating any custom components/services.
I think it would be interesting to develop a more fully fleshed out achievements/gamification system that could be dropped into an application, though. If this is something you would like to see me take further, let me know in the comments.
Before We Get Started
Last updated for Ionic 4, beta.15
This is an advanced tutorial that assumes you already have a decent working knowledge of the Ionic framework. If you require more introductory level content on Ionic I would recommend checking out my book or the Ionic tutorials on my website.
This tutorial will also require some level of familiarity with the Angular Animations API.
Set Up the Angular Animations API
Before we jump into the code, we will need to install the Angular Animations API in our Ionic application. To do that, you will just need to install the following package:
npm install @angular/animations --save
and you will also need to add the BrowserAnimationsModule
to your app.module.ts file:
import{ NgModule }from'@angular/core';import{ BrowserModule }from'@angular/platform-browser';import{ RouteReuseStrategy }from'@angular/router';import{ IonicModule, IonicRouteStrategy }from'@ionic/angular';import{ SplashScreen }from'@ionic-native/splash-screen/ngx';import{ StatusBar }from'@ionic-native/status-bar/ngx';import{ AppComponent }from'./app.component';import{ AppRoutingModule }from'./app-routing.module';import{ BrowserAnimationsModule }from'@angular/platform-browser/animations';
@NgModule({
declarations:[AppComponent],
entryComponents:[],
imports:[BrowserModule, BrowserAnimationsModule, IonicModule.forRoot(), AppRoutingModule],
providers:[
StatusBar,
SplashScreen,{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
bootstrap:[AppComponent]})exportclassAppModule{}
The Template
Before we work on the animation, we are going to set up the basic template both for our home page and the overlay that we will be animating.
Modify src/app/home/home.page.html to reflect the following:
<ion-header><ion-toolbarcolor="danger"><ion-title>
Achievements
</ion-title></ion-toolbar></ion-header><ion-contentpadding><div*ngIf="displayAchievement"class="achievement-container"><divclass="medal"><imgsrc="assets/medal.png"/></div><divclass="message"><h5>Tutorial Completed</h5></div></div><p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent lacus neque, imperdiet ac maximus at, sagittis vel turpis. Nullam ac enim vel ante tincidunt iaculis. Suspendisse ultrices luctus orci, a bibendum nisi consectetur consequat. Nam rhoncus nec dolor pulvinar porta. Aliquam erat volutpat. Nunc pretium mauris ipsum, quis pulvinar tellus tincidunt sit amet. Integer auctor ultrices rutrum. Suspendisse faucibus, urna in consectetur rutrum, orci turpis vestibulum nibh, eu fermentum nisi elit dignissim libero. Donec vestibulum, diam aliquet aliquet dapibus, quam nisi mattis neque, nec elementum mauris justo at tortor. Sed tempus sed nisi id tincidunt. In enim nisi, consectetur eget euismod semper, gravida ac ligula.
</p><ion-button(click)="test()"color="light"fill="outline">Test Achievement</ion-button></ion-content>
Aside from the basic layout, we have added a <div>
that contains our achievement overlay and its child elements. Eventually, we will be animating this. I am using a badge from Kenney’s assets as the achievement icon, but you can use whatever you like. We have also added a button to trigger the animation.
We will need to define the corresponding event binding for that button, along with the displayAchievement
class member:
Modify src/app/home/home.page.ts to reflect the following:
import{ Component }from'@angular/core';import{ ToastController }from'@ionic/angular';import{ trigger, style, animate, transition, group, query, animateChild }from'@angular/animations';
@Component({
selector:'app-home',
templateUrl:'home.page.html',
styleUrls:['home.page.scss'],})exportclassHomePage{public displayAchievement: boolean =false;constructor(private toastCtrl: ToastController){}test(){this.toastCtrl.create({
message:'Achievement unlocked!',
duration:2000}).then((toast)=>{
toast.present();});this.displayAchievement =true;setTimeout(()=>{this.displayAchievement =false;},3000);}}
Our test
method handles toggling the displayAchievement
class member on an off. We also trigger a toast to display the “Achievement unlocked!” message at the bottom of the screen.
We will also need to add a few styles to our overlay:
Modify src/app/home/home.page.scss to reflect the following:
.achievement-container{z-index: 100;width: 100%;height: 100%;background-color:rgba(0, 0, 0, 0.8);display: flex;align-items: center;justify-content: center;flex-direction: column;position: absolute;top: 0;left: 0;}h5{color: #fff;text-transform: uppercase;font-weight: lighter;font-size: 2em;text-shadow: -1px 2px 15px rgba(0, 0, 0, 0.7);}
Container Animation
With the basic layout set up, let’s focus on creating the animations. We will start by just animating the container itself. This is going to be a simple opacity animation that transitions from 0
opacity to 1
opacity.
Add the following animation to src/app/home/home.page.ts:
@Component({
selector:'app-home',
templateUrl:'home.page.html',
styleUrls:['home.page.scss'],
animations:[trigger('container',[transition(':enter',[style({opacity:'0'}),group([animate('500ms ease-out',style({opacity:'1'}))])]),transition(':leave',[group([animate('500ms ease-out',style({opacity:'0'}))])])])]})
We are defing transitions on both :enter
and :leave
(which is the equivalent of void => *
and * => void
respectively, it is just easier to write).
For the :enter
animation we start with an opacity of 0
and then we animate to an opacity of 1
over 500ms
with an ease-out
easing. The :leave
animation is mostly the same, just in reverse.
Notice that we are using an animation group
here. This isn’t required for a single animation, but eventually we will be triggering the animations for our child elements within those groups.
We will need to add the container
trigger to the container in our template.
Add the
@container
trigger to the container in home.page.html:
<div*ngIf="displayAchievement"class="achievement-container"@container>
If you were to run the animation now, you would see the achievement overlay fade in and then fade back out again. It doesn’t actually look too bad with just this, but we are going to take it a step further by animating the badge and text elements as well.
Child Element Animations
We are animating the element that contains both of the other elements we also want to animate. There is a bit of a trick to triggering these animations as they won’t run by default.
We are going to add triggers for both the badge
and the message
, but we will also need to add them to the animation group and trigger their animations manually.
Modify the animations in home.page.ts to reflect the following:
@Component({
selector:'app-home',
templateUrl:'home.page.html',
styleUrls:['home.page.scss'],
animations:[trigger('container',[transition(':enter',[style({opacity:'0'}),group([animate('500ms ease-out',style({opacity:'1'})),query('@badge, @message',[animateChild()])])]),transition(':leave',[group([animate('500ms ease-out',style({opacity:'0'})),query('@badge, @message',[animateChild()])])])]),trigger('badge',[transition(':enter',[style({transform:'translateY(400%)'}),animate('500ms ease-out',style({transform:'translateY(0)'}))]),transition(':leave',[animate('500ms ease-in',style({transform:'translateY(400%)'}))])]),trigger('message',[transition(':enter',[style({opacity:'0'}),animate('500ms 1000ms ease-out',style({opacity:'1'}))]),transition(':leave',[animate('500ms ease-in',style({opacity:'0'}))])])]})
Let’s discuss the animations themselves first. We have added triggers for badge
and message
- both with corresponding enter
and leave
animations.
The badge animation will cause the badge to fly in from the bottom, as we are animating translateY
. When the enter animation is triggered the badge will start off screen and fly to its normal position. On leaving, the badge will begin to move off screen again.
The message animation is just another simple opacity animation. However, we add a delay on this animation so that it triggers after the other elements have already animated in. I think this adds a little more intrigue to the animation.
We will need to add these triggers to our template, but that in itself isn’t enough. We have also added these animations to the animation group:
trigger('container',[transition(':enter',[style({opacity:'0'}),group([animate('500ms ease-out',style({opacity:'1'})),query('@badge, @message',[animateChild()])])]),transition(':leave',[group([animate('500ms ease-out',style({opacity:'0'})),query('@badge, @message',[animateChild()])])])]),
We query those elements by their trigger names, and then we manually run the animation by calling animateChild
, which will trigger the animations we have already defined.
Now we just need to add those triggers to the template.
Modify the badge and message elements in home.page.html to reflect the following:
<divclass="medal"@badge><imgsrc="assets/medal.png"/></div><divclass="message"@message><h5>Tutorial Completed</h5></div>
If you run the animation now, you should have something that looks like this:
Summary
Although the focus for this tutorial has just been on the animations, there is room to improve this functionality to be useful in a more general sense. Right now, it is hard coded and tied to the home page, but there is the potential to develop this into something that could be triggered dynamically.
If this would be of interest to you, rather than just the animations themselves, let me know in the comments.
Adding Sound Effects to an Ionic Application
In this tutorial, we are going to cover how we can add sound effects to our Ionic applications. This particular example is going to cover adding a “click” sound effect that is triggered when switching between tabs, but we will be creating a service that can be used generally to play many different sound effects in whatever situation you would like.
I wrote a tutorial on this subject a while ago, but I wanted to publish a new tutorial for a couple of reasons:
- It was a long time ago and I think there is room for improvement
- The Cordova plugin that Ionic Native uses for native audio does not appear to work with Capacitor, so I wanted to adapt the service to be able to rely entirely on web audio if desired.
We will be creating an audio service that can handle switching between native and web audio depending on the platform that the application is running on. However, we will also be adding an option to force web audio even in a native environment which can be used in the case of Capacitor (at least until I can find a simple solution for native audio in Capacitor).
In any case, I don’t think using web audio for sound effects really has any obvious downsides in a native environment anyway.
Before We Get Started
Last updated for Ionic 4, beta.16
This tutorial assumes that you already have a decent working knowledge of the Ionic framework. If you need more of an introduction to Ionic I would recommend checking out my book or the Ionic tutorials on my website.
1. Install the Native Audio Plugin
In order to enable support for native audio, we will need to install the Cordova plugin for native audio as well as the Ionic Native package for that plugin.
Keep in mind that if you are using Capacitor, plugins should be installed using npm install
not ionic cordova plugin add
.
2. Creating an Audio Service
First, we are going to add an Audio service to our application. We can do that by running the following command:
ionic g service services/Audio
The role of this service will be to preload our audio assets and to play them on demand. It will be smart enough to handle preloading the audio in the correct manner, as well as using the correct playing mechanism based on the platform.
Modify src/app/services/audio.service.ts to reflect the following:
import{ Injectable }from'@angular/core';import{ Platform }from'@ionic/angular';import{ NativeAudio }from'@ionic-native/native-audio/ngx';interfaceSound{
key: string;
asset: string;
isNative: boolean
}
@Injectable({
providedIn:'root'})exportclassAudioService{private sounds: Sound[]=[];private audioPlayer: HTMLAudioElement =newAudio();private forceWebAudio: boolean =true;constructor(private platform: Platform,private nativeAudio: NativeAudio){}preload(key: string, asset: string):void{if(this.platform.is('cordova')&&!this.forceWebAudio){this.nativeAudio.preloadSimple(key, asset);this.sounds.push({
key: key,
asset: asset,
isNative:true});}else{let audio =newAudio();
audio.src = asset;this.sounds.push({
key: key,
asset: asset,
isNative:false});}}play(key: string):void{let soundToPlay =this.sounds.find((sound)=>{return sound.key === key;});if(soundToPlay.isNative){this.nativeAudio.play(soundToPlay.asset).then((res)=>{
console.log(res);},(err)=>{
console.log(err);});}else{this.audioPlayer.src = soundToPlay.asset;this.audioPlayer.play();}}}
In this service, we are keeping track of an array of sounds
which will contain all of the audio assets that we want to play.
We create an audioPlayer
object for playing web audio (which is the same as having an <audio src="whatever.mp3">
element). We will use this single audio object and switch out the src
to play various sounds. The forceWebAudio
flag will always use web audio to play sounds when enabled.
The purpose of the preload
method is to load the sound assets and make them available for use. We supply it with a key
that we will use to reference a particular sound effect and an asset
which will link to the actual audio asset.
In the case of native audio, the sound is preloaded with the native plugin. For web audio, we create a new audio object and set its src
which will load the audio asset into the cache - this will allow us to immediately play the sound later rather than having to load it on demand.
The play
method allows us to pass it the key
of the sound we want to play, and then it will find it in the sounds
array. We when either play that sound using the native audio plugin, or we set the src
property on the audioPlayer
to that asset and then call the play
method.
3. Preloading Sound Assets
Before we can trigger playing our sounds, we need to pass the sound assets to the preload
method we just created. Since we want to play a sound when switching tabs for this example, we could preload the sound in the TabsPage component.
Modify src/app/tabs/tabs.page.ts to reflect the following:
import{ Component, AfterViewInit }from'@angular/core';import{ AudioService }from'../services/audio.service';
@Component({
selector:'app-tabs',
templateUrl:'tabs.page.html',
styleUrls:['tabs.page.scss']})exportclassTabsPageimplementsAfterViewInit{constructor(private audio: AudioService){}ngAfterViewInit(){this.audio.preload('tabSwitch','assets/audio/clickSound.mp3');}}
This will cause the sound to be loaded (whether through web or native audio) and be available for use. You don’t have to preload the sound here - you could preload the sound from anywhere (perhaps you would prefer to load all of your sounds in the root component).
NOTE: You will need to add a sound asset to your assets
folder. For this example, I used a “click” sound from freesound.org. Make sure to check that the license for the sounds you want to use match your intended usage.
4. Triggering Sounds
Finally, we just need to trigger the sound in some way. All we need to do to achieve this is to call the play
method on the audio service like this:
this.audio.play('tabSwitch');
Let’s complete our example of triggering the sound on tab changes.
Modify src/app/tabs/tabs.page.ts to reflect the following:
import{ Component, AfterViewInit, ViewChild }from'@angular/core';import{ Tabs }from'@ionic/angular';import{ AudioService }from'../services/audio.service';import{ skip }from'rxjs/operators';
@Component({
selector:'app-tabs',
templateUrl:'tabs.page.html',
styleUrls:['tabs.page.scss']})exportclassTabsPageimplementsAfterViewInit{
@ViewChild(Tabs) tabs: Tabs;constructor(private audio: AudioService){}ngAfterViewInit(){this.audio.preload('tabSwitch','assets/audio/clickSound.mp3');this.tabs.ionChange.pipe(skip(1)).subscribe((ev)=>{this.audio.play('tabSwitch');});}}
We are grabbing a reference to the tabs component here, and then we subscribe to the ionChange
observable which triggers every time the tab changes. The ionChange
will also fire once upon initially loading the app, so to avoid playing the sound the first time it is triggered we pipe
the skip
operator on the observable to ignore the first time it is triggered.
If you are interested in learning more about the mechanism I am using here to detect tab switching, you might be interested in one of my recent videos: Using Tab Badges in Ionic.
Summary
Loading and playing audio programmatically can be a bit cumbersome, but with the service we have created we can break it down into just two simple method calls - one to load the asset, and one to play it.
Using MongoDB with Ionic and NestJS
In the previous few NestJS tutorials we have been discussing how to set up a basic REST API that we can interact with using an Ionic application. We have covered making both GET and POST requests to the backend, but so far we have just been using dummy data and placeholder code - our API doesn’t really do anything yet.
In this tutorial, we will be covering how to integrate MongoDB into a NestJS backend and how to use that to add and retrieve records from an Ionic application. We will be walking through an example where we POST
data to the NestJS backend which will then be stored in a MongoDB database, and we will also make a GET
request to the NestJS backend in order to retrieve data from the MongoDB database to display in our Ionic application.
If you are not already familiar with creating a NestJS server, or if you are unfamiliar with making GET
and POST
requests from an Ionic application to a NestJS server, I would recommend reading through the other tutorials in this series first:
- An Introduction to NestJS for Ionic Developers
- Using Providers and HTTP Requests in a NestJS Backend
- Sending Data with POST Requests to a NestJS Backend
We will be continuing on from the code in the last tutorial above. Although you do not have to complete the previous tutorials in order to do this one, if you want to follow along step-by-step it will help to have already completed the previous tutorials.
MongoDB and Mongoose
This tutorial is going to focus on covering how to integrate MongoDB with Ionic and NestJS more than explaining the concept of MongoDB in general. In short, MongoDB is a document based NoSQL database and Mongoose is a library that allows us to define objects/schemas that represent the types of data/documents we want to store. Mongoose provides methods that make it easier to create and retrieve the data we are working with (and it also does a lot more than just that). MongoDB is the database that stores the data, and Mongoose makes it easier to interact with that database.
If you would like more information on both of these technologies, this tutorial is a particularly good introduction.
1. Installing MongoDB and Mongoose
In order to work with MongoDB on your machine, you will need to have it installed. If you do not already have MongoDB installed on your machine, you can find information on how to do that here. I also released a video recently that covers installing MongoDB on macOS: Installing MongoDB with Homebrew on macOS.
Once you have installed MongoDB, you will need to make sure to open a separate terminal window and run the following command:
mongod
This will start the MongoDB daemon, meaning that the database will be running in the background on your computer and you will be able to interact with it. You will also need to install the mongoose package and the NestJS package associated with that by running the following command in your NestJS server project:
npm install --save @nestjs/mongoose mongoose
The NestJS Backend
First, we are going to work on our NestJS server. We will walk through setting up a connection to the database, creating a schema to represent the data we want to store, creating a service to handle adding and retrieving records, and setting up the appropriate routes in the controller.
1. Connecting to MongoDB
In order to connect to MongoDB in our NestJS server, we need to add the MongooseModule
to our root module. We will use the forRoot
method to supply the connection address, which is exactly the same as what we would use if we were just using the standard mongoose.connect
method described here.
Modify src/app.module.ts to reflect the following:
import{ Module, HttpModule }from'@nestjs/common';import{ MongooseModule }from'@nestjs/mongoose';import{ AppController }from'./app.controller';import{ AppService }from'./app.service';
@Module({
imports:[
HttpModule,
MongooseModule.forRoot('mongodb://localhost/mydb')],
controllers:[AppController],
providers:[AppService]})exportclassAppModule{}
As you can see, we supply mongodb://localhost/mydb
to the MongooseModule
which will set up a connection to the mydb
MongoDB database on localhost
. Keep in mind that in a production environment this connection address would be different.
If you have been following along with the previous tutorials you might notice that we have made some other changes to this module. Previously, we had included a QuotesService
and a MessagesController
in this module. We will be getting rid of the QuotesService
as this was just an example for a previous tutorial. Instead of adding the MessagesController
directly to the root module, we are going to give the messages functionality its own module that we will import into the root module later. Since the Messages
functionality is becoming a little more complex now, it is going to be neater to organise the functionality into its own module. Even though that isn’t strictly required, it does allow for better code organisation.
2. Create a Message Schema
As I mentioned before, we can use Mongoose to define the type of data we want to store in the database. A “schema” represents the structure of the data that we want to store - if you are familiar with types and interfaces it is basically the same concept.
Create a file at src/messages/message.schema.ts and add the following:
import*as mongoose from'mongoose';exportconst MessageSchema =newmongoose.Schema({
content: String,
submittedBy: String
});
We create a new Schema with new mongooose.Schema
and we supply the properties that we want that schema to contain along with the types for those properties. The type of data that we are adding to this schema is the same as what we have defined in the message.dto.ts file in the previous tutorials (this represents the Data Transfer Object (DTO) used to POST
data to a NestJS server). It makes sense that these match because we intend to store the same data that we will POST
to the NestJS server in the MongoDB database.
3. Create the Messages Module
We are going to create the Messages
module now as we need this to set up a Model
based on our MessageSchema
which will allow us to interact with messages in our database. This module will set up all of the message related functionality that we need, and then we can import this single module into our main root module (rather than having to add all of these things individually to app.module.ts).
Create a file at src/messages/messages.module.ts and add the following:
import{ Module }from'@nestjs/common';import{ MongooseModule }from'@nestjs/mongoose';import{ MessagesController }from'./messages.controller';import{ MessagesService }from'./messages.service';import{ MessageSchema }from'./message.schema';
@Module({
imports:[MongooseModule.forFeature([{name:'Message', schema: MessageSchema}])],
controllers:[MessagesController],
providers:[MessagesService]})exportclassMessagesModule{}
We use MongooseModule.forFeature
to set up our Message
model that we will make use of in a moment - this is based on the MessageSchema
that we just created. Also, notice that we are importing MessagesService
and adding it as a provider - we haven’t created this yet but we will in the next step.
4. Create a Messages Service
Now we are going to create a messages service that will handle adding documents to the MongoDB database and retrieving them.
Create a file at src/messages/messages.service.ts and add the following:
import{ Injectable }from'@nestjs/common';import{ Model }from'mongoose';import{ InjectModel }from'@nestjs/mongoose';import{ MessageDto }from'./message.dto';import{ Message }from'./message.interface';
@Injectable()exportclassMessagesService{constructor(@InjectModel('Message')private messageModel: Model<Message>){}asynccreateMessage(messageDto: MessageDto): Promise<Message>{const message =newthis.messageModel(messageDto);returnawait message.save();}asyncgetMessages(): Promise<Message[]>{returnawaitthis.messageModel.find().exec();}asyncgetMessage(id): Promise<Message>{returnawaitthis.messageModel.findOne({_id: id});}}
We are creating three different methods here:
- A
createMessage
method that will add a new document to the database - A
getMessages
method that will return all message documents from the database - A
getMessage
method that will return one specific document from the database (based on its_id
)
In the constructor
we add @InjectModel('Message')
which will inject our Message
model (which we just set up in the messages module file) into this class. We will be able to use this model to create new messages and retrieve them from the database.
Our createMessage
method accepts the messageDto
which will POST
from our Ionic application to the NestJS backend. It then creates a new message model using the data from this DTO, and then calls the save
method which will add it to the MongoDB database. We are returning the result of the save
operation which will allow us to see the document that was added.
The getMessages
method we call the find
method on the messages model which will return all message documents from the database (as an array).
The getMessage
method will accept an id
parameter, and then it will return one document from the database that matches the id
that was supplied. MongoDB _id
fields are generated automatically if you add a document to the database that does not contain an _id
.
Before this will work, we will need to define an interface
for our messages since we are using Message
as a type in this service.
Create a file at src/messages/message.interface.ts and add the following:
exportinterfaceMessage{
content: string;
submittedBy: string;}
5. Create the Routes
Now we need to create the appropriate routes in the message controller.
Modify src/messages/messages.controller.ts to reflect the following:
import{ Controller, Get, Post, Body, Param }from'@nestjs/common';import{ MessageDto }from'./message.dto';import{ MessagesService }from'./messages.service';
@Controller('messages')exportclassMessagesController{constructor(private messagesService: MessagesService){}
@Post()asynccreateMessage(@Body() message: MessageDto){returnawaitthis.messagesService.createMessage(message);}
@Get()asyncgetMessages(){returnawaitthis.messagesService.getMessages();}
@Get(':id')asyncgetMessage(@Param('id') id: String){returnawaitthis.messagesService.getMessage(id);}}
I’ve already covered the concepts used above in the previous tutorials, so if you aren’t familiar with what is happening here make sure to check those out.
6. Import the Messages Module
Finally, we just need to import our messages module into the root module for the application.
Modify src/app.module.ts to reflect the following:
import{ Module, HttpModule }from'@nestjs/common';import{ MongooseModule }from'@nestjs/mongoose';import{ AppController }from'./app.controller';import{ AppService }from'./app.service';import{ MessagesModule }from'./messages/messages.module';
@Module({
imports:[
HttpModule,
MessagesModule,
MongooseModule.forRoot('mongodb://localhost/mydb')],
controllers:[AppController],
providers:[AppService]})exportclassAppModule{}
As you can see above, we can set up all of that messages functionality we just created with one clean and simple import in our root module.
The Ionic Frontend
Now we just need to update our frontend to make use of the new backend functionality - fortunately, there isn’t much we need to do here. It doesn’t really have to be an Ionic/Angular application either, as we are just interacting with a REST API.
1. Modify the Messages Service
Our frontend also has a messages service to handle making the calls to the backend, we will need to update that.
Modify src/app/services/messages.service.ts to reflect the following:
import{ Injectable }from'@angular/core';import{ HttpClient }from'@angular/common/http';import{ Observable }from'rxjs'
@Injectable({
providedIn:'root'})exportclassMessagesService{constructor(private http: HttpClient){}createMessage(message): Observable<Object>{returnthis.http.post('http://localhost:3000/messages',{
content: message.content,
submittedBy: message.submittedBy
});}getMessages(): Observable<Object>{returnthis.http.get('http://localhost:3000/messages');}getMessage(id): Observable<Object>{returnthis.http.get(`http://localhost:3000/messages/${id}`);}}
- Trigger Calls to the API
Now we just need to make some calls to the API we created. We will just trigger these from the home page.
Modify src/app/home/home.page.ts to reflect the following:
import{ Component, OnInit }from'@angular/core';import{ MessagesService }from'../services/messages.service';
@Component({
selector:'app-home',
templateUrl:'home.page.html',
styleUrls:['home.page.scss'],})exportclassHomePageimplementsOnInit{constructor(private messages: MessagesService){}ngOnInit(){let testMessage ={
content:'Hello!',
submittedBy:'Josh'};let testId ='5c04b73880159ab69b1e29a9'// Create a test messagethis.messages.createMessage(testMessage).subscribe((res)=>{
console.log("Create message: ", res);});// Retrieve all messagesthis.messages.getMessages().subscribe((res)=>{
console.log("All messages: ", res);});// Retrieve one specific messagethis.messages.getMessage(testId).subscribe((res)=>{
console.log("Specific message: ", res);});}}
This will trigger three separate calls to each of the different routes we added to the server. In the case of retrieving a specific message, I am using a testId
- in order for you to replicate this, you will first need to add a document to the database and then copy its _id
as the testId
here. The first time you serve this application in the browser you will be able to see the _id
of one of the documents that are created.
To run this code you will need to:
- Make sure that the MongoDB daemon is running by executing the
mongod
command in a separate terminal window - Make sure that your NestJS server is running by executing the
npm run start
command in another terminal window - Serve your Ionic application by executing the
ionic serve
command in another terminal window
When you run the application, you should see something like this output to the console:
You can see the document that was just created, all of the documents currently in the database, and the specific document that matches the testId
(assuming that you have set up a testId
that actually exists).
Summary
The built-in support for MongoDB that NestJS provides makes for a really smooth experience if you want to use MongoDB as your database. You will be able to use all the features you would expect from Mongoose within an Angular style application architecture. We have only scratched the surface of what you might want to do with a MongoDB integration here, in future tutorials we are going to focus on more advanced concepts like adding authentication.
Adding JWT Authentication to an Ionic Application with MongoDB and NestJS
Many Ionic applications will require the concept of an authenticated user who is authorised to perform certain actions - perhaps only authenticated users may access your application at all, or perhaps you want to restrict certain types of functionality in your application to certain types of users.
There are many different options that can be used to add this functionality to your application, including services like Firebase and Auth0 which do most of the heavy lifting for you and provide a simple API for authenticating users. However, you can also create your own authentication/authorisation functionality, which may be a more attractive option if you are taking a self-hosted approach to your backend.
In this tutorial, we are going to cover how to create our own authentication system for an Ionic application with MongoDB (to store users other data) and NestJS (to handle HTTP requests to the backend). The basic flow will work like this:
- A user will create an account (if they do not have one already)
- The account will be stored in the MongoDB database (if it has not been already)
- The user will supply their email and password to sign in to the application
- If the sign in is successful, the user will be supplied with a JWT that identifies them
- Requests to any restricted routes will require that the JWT is sent along with the request in order to access it
I will not be covering what JSON Web Tokens (JWT) are in this tutorial. If you are not already familiar with the concept, I would recommend reading this article. In short, a JWT supplied to a user cannot be modified by the user (without breaking the signature), and so if we supply a JWT to the user that says they are Natalie
, we can trust that is true since we know the user couldn’t have just edited it themselves on the client-side. If the someone were to try to modify a JWT (e.g. changing Natalie
to Josh
) then when the JWT is checked on the server we would be able to tell that it was tampered with (and thus reject the request). This concept means we can check a users authorisation without needing their password after they have signed in initially.
A critical point about a JWT is that they cannot be modified not that they can’t be read. A JWT is encoded and that may give the illusion that you could store sensitive data in the JWT, but you should definitely never do this as a JWT can be easily decoded by anybody. A JWT is good for storing information like a user_id
, an email
, or a username
, but never something sensitive like a password
. Another important thing to keep in mind about a JWT is that anybody who has it has the ability to be authorised as the “true” owner of the JWT (e.g. if someone managed to steal a JWT from someone, they may be able to perform actions that they should not be authorised to do). We will be taking a very simple approach in this tutorial, but keep in mind that other security measures are often used in conjunction with a JWT.
IMPORTANT: This tutorial is for learning purposes only. It has not been rigorously tested and it is not a plug-and-play solution. Authentication/authorisation and security in general, are important and complex topics. If you are creating anything where security is important, you should always engage the help of an expert with relevant knowledge.
Before We Get Started
Although this tutorial is completely standalone, it continues on from the concepts that we have covered in the previous NestJS tutorials:
- An Introduction to NestJS for Ionic Developers
- Using Providers and HTTP Requests in a NestJS Backend
- Sending Data with POST Requests to a NestJS Backend
- Using MongoDB with Ionic and NestJS
If you have not already read these, or you are not already familiar with the basic NestJS concepts, I would recommend that you read those first as I won’t be explaining those concepts in this tutorial.
In order to work with MongoDB on your machine, you will need to have it installed. If you do not already have MongoDB installed on your machine, you can find information on how to do that here. I also released a video recently that covers installing MongoDB on macOS: Installing MongoDB with Homebrew on macOS.
Once you have installed MongoDB, you will need to make sure to open a separate terminal window and run the following command:
mongod
This will start the MongoDB daemon, meaning that the database will be running in the background on your computer and you will be able to interact with it.
1. Create a New NestJS Application
We will start by creating a fresh new NestJS application, which we can do with the following command:
nest new nest-jwt-auth
We are also going to take care of installing all of the dependencies we require as well.
npm install --save @nestjs/mongoose mongoose
This will install mongoose
and the associated NestJS package that we will use to interact with our MongoDB database.
npm install --save bcrypt
We will be using bcrypt
to hash the user passwords that we will be storing in the database.
npm install --save passport
npm install --save passport-jwt
npm install --save @nestjs/jwt
npm install --save @nestjs/passport
We will be using the Passport library to implement authentication “strategies” - this helps us define the process that will be used to determine whether a user is authorised to access certain routes or not. We will be implementing a JWT strategy, so we also require the JWT packages.
Before we move on, we are also going to enable CORS which will allow us to make cross-domain requests to our NestJS server.
Modify src/main.ts to reflect the following:
import{ NestFactory }from'@nestjs/core';import{ AppModule }from'./app.module';asyncfunctionbootstrap(){const app =await NestFactory.create(AppModule);
app.enableCors();await app.listen(3000);}bootstrap();
2. Set up the MongoDB Connection
In order to interact with our MongoDB database, we will need to set up the connection in the root module of our NestJS application.
Modify src/app.module.ts to reflect the following:
import{ Module }from'@nestjs/common';import{ MongooseModule }from'@nestjs/mongoose';import{ AppController }from'./app.controller';import{ AppService }from'./app.service';
@Module({
imports:[
MongooseModule.forRoot('mongodb://localhost/authexample')],
controllers:[AppController],
providers:[AppService],})exportclassAppModule{}
In this particular example, we will be using a database called authexample
but you can name this whatever you like (there is no need to “create” the database beforehand).
3. Create the Users Module
Now that we have all of the plumbing out of the way, we can move on to building our authentication functionality. We are going to start off by creating a users
module that will contain all of the functionality related to creating and finding users.
Run the following command to create the users module
nest g module Users
By using the g
command to generate the module for us, the NestJS CLI will automatically import the module into our root app.module.ts module for us. If you create your modules manually, you will also need to manually add the module to your root module.
We can also use the NestJS CLI to automatically create our users
controller for us (which will handle the various routes available on the server) and our users
service (which will handle any logic for us).
Run the following commands:
nest g controller users
nest g service users
The controller and the service will also automatically be added to the users module since we have used the generate command. We will get to implementing the controller and the service soon, but there are still some additional files we need to create.
Create a file at src/users/dto/create-user.dto.ts and add the following:
exportclassCreateUserDto{
readonly email: string;
readonly password: string;}
If you recall, we use DTOs (Data Transfer Objects) to define the structure of the data that we want to be able to POST
to our server (from our Ionic application or from whatever frontend we are using). This particular DTO defines the data that we will be sending when we want to create a new user. We also need to define a DTO for when a user wants to log in.
Create a file at src/users/dto/login-user.dto.ts and add the following:
exportclassLoginUserDto{
readonly email: string;
readonly password: string;}
These two DTOs are exactly the same, so technically, we don’t really need both in this case. However, it would be common that when creating a user you might also want to accept some additional information (perhaps an age, address, account type, and so on).
Create a file at src/users/user.interface.ts and add the following:
exportinterfaceUser{
email: string
}
This file just defines a simple type that we will be able to use with our User objects in the application.
Create a file at src/users/user.schema.ts and add the following:
import*as mongoose from'mongoose';import*as bcrypt from'bcrypt';exportconst UserSchema =newmongoose.Schema({
email:{
type: String,
unique:true,
required:true},
password:{
type: String,
required:true}});// NOTE: Arrow functions are not used here as we do not want to use lexical scope for 'this'
UserSchema.pre('save',function(next){let user =this;// Make sure not to rehash the password if it is already hashedif(!user.isModified('password'))returnnext();// Generate a salt and use it to hash the user's password
bcrypt.genSalt(10,(err, salt)=>{if(err)returnnext(err);
bcrypt.hash(user.password, salt,(err, hash)=>{if(err)returnnext(err);
user.password = hash;next();});});});
UserSchema.methods.checkPassword=function(attempt, callback){let user =this;
bcrypt.compare(attempt, user.password,(err, isMatch)=>{if(err)returncallback(err);callback(null, isMatch);});};
Now we are getting into something a bit more substantial, and this is actually a core part of how our authentication system will work. As we have talked about in previous tutorials, a Mongoose schema allows to more easily work with data in our MongoDB database. In this case, we are creating a schema to represent a User
.
The interesting part here is the save
function we are adding as well as the custom method. With Mongoose, we can specify a function we want to run whenever a document is going to be saved in the database. What will happen here is that when we want to create a new User
we will supply the email
and the password
that the user gives us, but we don’t want to just immediately save that to the database. If we did that, the password would just be stored in plain text - we want to hash the user’s password first.
Hashing is a one-way process (unlike encryption, which can be reversed) that we use to avoid storing a user’s password in plain-text in a database. Instead of checking a user’s password against the value stored in the database directly, we check the hashed version of the password they supply against the hashed version stored in the database. This way, we (as the database owner or anyone with access) won’t be able to actually see what the user’s password is, and perhaps more importantly if the database were compromised by an attacker they would not be able to retrieve the user’s passwords either. You should never store a user’s password without hashing it.
What happens with our save
function above is that rather than saving the original value, it will use bcrypt
to convert the password
field into a hashed version of the value supplied. Before doing that, we use isModified
to check if the password
value is being changed (if we were just updating some other information on the user, we don’t want to re-hash the password
field otherwise they would no longer be able to log in).
We define the checkPassword
method on the User
schema so that we have an easy way to compare a password from a login attempt to the hashed value that is stored. Before we can continue, we will need to set up our new User
schema in our users module (which will allow us to use it in our users service).
Modify src/users/users.module.ts to reflect the following:
import{ Module }from'@nestjs/common';import{ MongooseModule }from'@nestjs/mongoose';import{ PassportModule }from'@nestjs/passport';import{ UsersController }from'./users.controller';import{ UsersService }from'./users.service';import{ UserSchema }from'./user.schema';
@Module({
imports:[
MongooseModule.forFeature([{name:'User', schema: UserSchema}]),
PassportModule.register({ defaultStrategy:'jwt', session:false})],
exports:[UsersService],
controllers:[UsersController],
providers:[UsersService]})exportclassUsersModule{}
The important part here is the addition of forFeature
which will make our User
schema available to use throughout this module. We have also added JWT as the default strategy for the PassportModule
here - this is something we are going to get into more in the Auth
module, but we will need to add this PassportModule
import into any module that contains routes we want to protect with our JWT authorisation. Now we can move on to implementing our service.
Modify src/users/user.service.ts to reflect the following:
import{ Model }from'mongoose';import{ Injectable }from'@nestjs/common';import{ InjectModel }from'@nestjs/mongoose';import{ User }from'./user.interface';import{ CreateUserDto }from'./dto/create-user.dto';
@Injectable()exportclassUsersService{constructor(@InjectModel('User')private userModel: Model<User>){}asynccreate(createUserDto: CreateUserDto){let createdUser =newthis.userModel(createUserDto);returnawait createdUser.save();}asyncfindOneByEmail(email): Model<User>{returnawaitthis.userModel.findOne({email: email});}}
As you can see in the constructor
we are injecting our User
model, and we will be able to use all of the methods that it makes available. We create two functions in this service. The create
function will accept the data for creating a new user, and it will use that data to create a new user in MongoDB. The findOneByEmail
function will allow us to find a MongoDB record that matches the supplied email address (which will be useful when we are attempting authentication).
Finally, we just need to implement the controller.
Modify src/users/users.controller.ts to reflect the following:
import{ Controller, Get, Post, Body }from'@nestjs/common';import{ CreateUserDto }from'./dto/create-user.dto';import{ UsersService }from'./users.service';
@Controller('users')exportclassUsersController{constructor(private usersService: UsersService){}
@Post()asynccreate(@Body() createUserDto: CreateUserDto){returnawaitthis.usersService.create(createUserDto);}}
For now, we just need a single route. This will allow us to make a POST
request to /users
containing the data required to create a new user. Later, we are going to add an additional “protected” route here that will only be able to be accessed by authorised users.
4. Create the Auth Module
Now we can move on to the Auth
module which is going to handle authenticating users, creating JWTs, and checking the validity of JWTs when accessing a protected route. As I mentioned before, we are going to use Passport to create a JWT “strategy” which basically means the code/logic we want to run when attempting to authorise a user for a particular route.
First, we will need to create the module itself:
nest g module Auth
We will also create a controller and service for this module as well:
nest g controller auth
nest g service auth
As we had to do in the Users module, we will also need to create some additional files for our Auth module. We will start by creating an interface for our JWTs.
Create a file at src/auth/interfaces/jwt-payload.interface.ts and add the following:
exportinterfaceJwtPayload{
email: string;}
This is the type for the “payload” of our JWT (i.e. the data contained within the JWT). We are just using an email
which we can then use to identify the user by matching it against an email for a user stored in our MongoDB database. You could add other information to your JWT as well, but remember, do not store sensitive information like passwords in a JWT.
Modify src/auth/auth.service.ts to reflect the following:
import{ Injectable, UnauthorizedException }from'@nestjs/common';import{ JwtService }from'@nestjs/jwt';import{ LoginUserDto }from'../users/dto/login-user.dto';import{ UsersService }from'../users/users.service';import{ JwtPayload }from'./interfaces/jwt-payload.interface';
@Injectable()exportclassAuthService{constructor(private usersService: UsersService,private jwtService: JwtService){}asyncvalidateUserByPassword(loginAttempt: LoginUserDto){// This will be used for the initial loginlet userToAttempt =awaitthis.usersService.findOneByEmail(loginAttempt.email);returnnewPromise((resolve)=>{// Check the supplied password against the hash stored for this email address
userToAttempt.checkPassword(loginAttempt.password,(err, isMatch)=>{if(err)thrownewUnauthorizedException();if(isMatch){// If there is a successful match, generate a JWT for the userresolve(this.createJwtPayload(userToAttempt));}else{thrownewUnauthorizedException();}});});}asyncvalidateUserByJwt(payload: JwtPayload){// This will be used when the user has already logged in and has a JWTlet user =awaitthis.usersService.findOneByEmail(payload.email);if(user){returnthis.createJwtPayload(user);}else{thrownewUnauthorizedException();}}createJwtPayload(user){let data: JwtPayload ={
email: user.email
};let jwt =this.jwtService.sign(data);return{
expiresIn:3600,
token: jwt
}}}
Our auth service provides three different methods. The first two handle the two different authentication processes available. We have a validateUserByPassword
method that will be used when the user is initially logging in with their email
and password
. This method will find the user in the MongoDB database that matches the supplied email
and then it will invoke the custom checkPassword
method we added to the User
schema. If the hash of the supplied password matches the hash stored in the database for that user, the authentication will succeed. In that case, the method will return a JWT by calling the createJwtPayload
method.
The createJwtPayload
method will add the user’s email address to the payload, and then it will sign the JWT using the sign
method of the JwtService
that was injected into the constructor. This is what ensures that the JWT cannot be tampered with as it is signed by a secret key known only to the server (which we will need to set up in a moment).
The validateUserByJwt
method will be used when a user has already logged in and has been given a JWT. This will simply check that the email contained in the JWT represents a real user, and if it does it will return a new JWT (and the success of this method will be used to determine whether or not a user can access a particular route).
Modify src/auth/auth.controller.ts to reflect the following:
import{ Controller, Post, Body }from'@nestjs/common';import{ AuthService }from'./auth.service';import{ LoginUserDto }from'../users/dto/login-user.dto'
@Controller('auth')exportclassAuthController{constructor(private authService: AuthService){}
@Post()asynclogin(@Body() loginUserDto: LoginUserDto){returnawaitthis.authService.validateUserByPassword(loginUserDto);}}
Our controller is quite simple, we just have a single POST
route set up which will allow our frontend application to POST
a users email and password to the /auth
endpoint. This will then invoke the validateUserByPassword
method, and if it is successful it will return the JWT for the user.
Create a file at src/auth/strategies/jwt.strategy.ts and add the following:
import{ Injectable, UnauthorizedException }from'@nestjs/common';import{ ExtractJwt, Strategy }from'passport-jwt';import{ AuthService }from'../auth.service';import{ PassportStrategy }from'@nestjs/passport';import{ JwtPayload }from'../interfaces/jwt-payload.interface';
@Injectable()exportclassJwtStrategyextendsPassportStrategy(Strategy){constructor(private authService: AuthService){super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey:'thisismykickasssecretthatiwilltotallychangelater'});}asyncvalidate(payload: JwtPayload){const user =awaitthis.authService.validateUserByJwt(payload);if(!user){thrownewUnauthorizedException();}return user;}}
This is the “strategy” that we will use to authorise a user when they are attempting to access a protected route - this is where the Passport package comes in. In the constructor, we supply passport with the settings we want to use. We will be extracting the JWT from the Bearer
header that we will send along with any requests to the server, and the secret key that we will use to sign the JWTs is thisismykickasssecretthatiwilltotallychangelater
. You need to change this to a secret value that isn’t known to anybody else - if somebody knows the secret key you are using to sign your JWTs then they can easily create their own JWTs containing whatever information they like, and your server will see those JWTs as valid.
IMPORTANT: Don’t forget to change the secretOrKey
value.
The validate
method handles checking that the JWT supplied is valid by invoking the validateUserByJwt
method that we created earlier.
Modify src/auth/auth.module.ts to reflect the following:
import{ Module }from'@nestjs/common';import{ JwtModule }from'@nestjs/jwt';import{ AuthService }from'./auth.service';import{ AuthController }from'./auth.controller';import{ JwtStrategy }from'./strategies/jwt.strategy';import{ UsersModule }from'../users/users.module';import{ PassportModule }from'@nestjs/passport';
@Module({
imports:[
PassportModule.register({ defaultStrategy:'jwt', session:false}),
JwtModule.register({
secretOrPrivateKey:'thisismykickasssecretthatiwilltotallychangelater',
signOptions:{
expiresIn:3600}}),
UsersModule
],
controllers:[AuthController],
providers:[AuthService, JwtStrategy]})exportclassAuthModule{}
Now we just need to make a few changes to our Auth module. Once again, we set up the PassportModule
with the default strategy that will be used as we need to import this into any module where we want to add protected routes. We set up the NestJS JwtModule
with the values we want to use with our JWT (make sure to use the same secret key value as before, but make sure it is different to the one I am using).
5. Create a Restricted Route
With everything above in place, we now have an API that supports:
- Creating new users
- Authenticating users with an email and password
- Supplying users with a JWT
- Authenticating users with a JWT
- Restricting particular routes by enforcing that users require a valid JWT in order to access it
Although this is in place, we don’t actually have any protected routes. Let’s create a test route in our users controller.
Modify src/users/users.controller.ts to reflect the following:
import{ Controller, Get, Post, Body, UseGuards }from'@nestjs/common';import{ CreateUserDto }from'./dto/create-user.dto';import{ UsersService }from'./users.service';import{ AuthGuard }from'@nestjs/passport';
@Controller('users')exportclassUsersController{constructor(private usersService: UsersService){}
@Post()asynccreate(@Body() createUserDto: CreateUserDto){returnawaitthis.usersService.create(createUserDto);}// This route will require successfully passing our default auth strategy (JWT) in order// to access the route
@Get('test')
@UseGuards(AuthGuard())testAuthRoute(){return{
message:'You did it!'}}}
To protect a particular route, all we need to do is add @UseGuards(@AuthGuard())
to it and it will use our default JWT strategy to protect that route. With this in place, if we were to make a GET
request to /users/test
it would only work if we sent the JWT along with the request in the headers.
6. Test in an Ionic Application
With everything in place, let’s test it!
Everything we have done above really doesn’t have much to do with Ionic at all, you could use this backend with many types of frontends. In the end, all we will be doing is making GET
and POST
HTTP requests to the NestJS backend. Although you do not have to use Ionic, I am going to show you some rough code that you can use to test the functionality.
NOTE: The following code is purely for testing the API and is in no way designed well. Your Ionic application should not look like this. Keep in mind that you will need to have the HttpClientModule
set up in order for the following code to work.
Modify src/home/home.page.ts to reflect the following:
import{ Component }from'@angular/core';import{ HttpClient, HttpHeaders }from'@angular/common/http';
@Component({
selector:'app-home',
templateUrl:'home.page.html',
styleUrls:['home.page.scss'],})exportclassHomePage{public createEmail: string;public createPassword: string;public signInEmail: string;public signInPassword: string;public jwt: string;constructor(private http: HttpClient){}createAccount(){let credentials ={
email:this.createEmail,
password:this.createPassword
}this.http.post('http://localhost:3000/users', credentials).subscribe((res)=>{
console.log(res);});}signIn(){let credentials ={
email:this.signInEmail,
password:this.signInPassword
}this.http.post('http://localhost:3000/auth', credentials).subscribe((res: any)=>{
console.log(res);// NOTE: This is just for testing, typically you would store the JWT in local storage and retrieve from therethis.jwt = res.token;});}testRoute(){let headers =newHttpHeaders().set('Authorization','Bearer '+this.jwt)this.http.get('http://localhost:3000/users/test',{headers: headers}).subscribe((res)=>{
console.log(res);});}logout(){this.jwt =null;}}
Modify src/home/home.page.html to reflect the following:
<ion-header><ion-toolbar><ion-title>
Ionic Blank
</ion-title></ion-toolbar></ion-header><ion-contentpadding><h2>Create Account</h2><ion-input[(ngModel)]="createEmail"type="text"placeholder="email"></ion-input><ion-input[(ngModel)]="createPassword"type="password"placeholder="password"></ion-input><ion-button(click)="createAccount()"color="primary">Test Create Account</ion-button><h2>Sign In</h2><ion-input[(ngModel)]="signInEmail"type="text"placeholder="email"></ion-input><ion-input[(ngModel)]="signInPassword"type="password"placeholder="password"></ion-input><ion-button(click)="signIn()"color="primary">Test Sign In</ion-button><ion-button(click)="testRoute()"color="light">Test Protected Route</ion-button><ion-button(click)="logout()"color="light">Test Logout</ion-button></ion-content>
The important part in the code above is that we are setting the Authorization
header and sending our JWT as a bearer token. In order to run this example, you will need to make sure that you:
- Have the MongoDB daemon running with
mongod
- Have your NestJS backend being served by running
npm run start
- Have your Ionic application served with
ionic serve
If you are using the code above you should be able to go through the process of:
- Creating a user
- Signing in with that user
- Accessing the protected route
- Logging out (and you will no longer be able to access the protected route)
After creating a user or signing in, you should receive a JWT in the server response that will look something like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZ21haWwuY29tIiwiaWF0IjoxNTQ0NTcyNzM3LCJleHAiOjE1NDQ1NzYzMzd9.6p0XH9KkGsde9S38nDOkPudYk02dZK6xxtd3qqWFg3M
If you were to paste this JWT in the debugger at jwt.io you would be able to see that the payload of this JWT is:
{
"email": "test@gmail.com",
"iat": 1544572737,
"exp": 1544576337
}
However, you might notice that it says “Invalid Signature”. This is good, we should only get a valid signature when the secret key the JWT was signed with is supplied. If you were to first add the key thisismykickasssecretthatiwilltotallychangelater
to the “Verify Signature” section under “your-256-bit-secret”, and then add the JWT to the “Encoded” section you will see that the signature is valid. Since this key is only known to our server, only our server can validate or create JWTs.
In the test code, we are just saving the JWT on this.jwt
but you would typically save this in some kind of local storage.
If you wanted to test the security of the application you could even try modifying the values of the JWT manually, or supplying your own manually created JWTs to see if you can fool the authorisation process. After you have created a user, if you were to open a MongoDB shell in your terminal by running mongo
you should be able to run the following commands:
use authexample
users.find()
To retrieve a list of all of the users you have created. The users will look something like this:
{ "_id" : ObjectId("5c0dc18d349bc9479600c171"), "email" : "test@gmail.com", "password" : "$2b$10$2WLRRE/IWW.1yXcEyt0sZeFS/257w6SAaigbMNMfcqX1JNZ1KKXGO", "__v" : 0 }
You can see the password hashing in action here. I’ll let you in on a little secret - the test password I used for this was password
. But, there is no way you could know that just by looking at the value stored in the database:
$2b$10$2WLRRE/IWW.1yXcEyt0sZeFS/257w6SAaigbMNMfcqX1JNZ1KKXGO
Since this value is hashed (not encrypted) it means that this value should not be able to be reversed. We can’t read what the password is, and neither could an attacker.
Summary
This tutorial wasn’t exactly a simple one but without too much work we now have a fully functional authentication/authorisation system that can remember users after their initial login. I’d like to stress again that this is primarily for educational purposes and has not been seriously tested - please make sure to do your own research and testing if you intend to use any of this code in a production environment.
Creating & Using a Headless CMS with an Ionic Application
A Content Management System (CMS) provides an easy way to store and manage data related to content that you want to display. Popular Content Management Systems include the likes of Wordpress, Drupal, Magento and many more. One of the main benefits of a CMS is that it allows you to manage content without having to worry about the code - once the website or application is built you can typically manage everything through some kind of administration interface rather than having to push updates to the code (which also means that they are a viable option for people who do not have permanent developers available).
In this tutorial, we are going to focus on using a Headless CMS with Ionic. A typical CMS would not only allow you to store and manage your content, but it would also provide a way to display that content - a headless CMS does not do this. Instead, a headless CMS provides an API to access the data - the headless CMS handles the data, and it is up to you to decide how you want to display that data - in this case, we want to display the data in an Ionic application like this:
Using a Headless CMS with Ionic
It is relatively easy to pull data into an Ionic application through an API, so a headless CMS can be a fantastic way to manage data in an Ionic application.
You can use many different platforms as a headless CMS even if they are not specifically marketed that way. As long as you can store/manage data through the platform, and you can query that data through an API, you can use it as a headless CMS. For example, Wordpress provides an API that you can use to access your data, so you could even use Wordpress as a headless CMS if you built your own front-end instead of using Wordpress to display the user interface.
You could use something simple like Google Sheets as a headless CMS, or you could use something that is more specifically targeted to this purpose like Contentful. You can use anything as simple or as complex as you like - as long as you can query an API to pull in the data you can use it as a headless CMS with your Ionic application.
We will be using Airtable in this tutorial. Airtable allows you to store/manage data in a table/spreadsheet type of environment, and it provides an excellent API for accessing the data that you add.
There is much, much more to Airtable than just being a simple online spreadsheet with an API, but that is all we will need it to be for this tutorial. Once we have finished this tutorial we will be able to update/add to the spreadsheet above as we please, and then that data will automatically be reflected in our Ionic application the next time it is reloaded.
1. Sign up for Airtable
You will first need to create an account with Airtable. There are paid plans available with different features, but the free tier is enough for the sake of this tutorial (you might need to investigate limits if you intend to use this in a production environment).
2. Set up a Workspace and Base
Once you have signed up, you will need to add a new workspace by clicking + Add a workspace:
As you can see in the image above, I have created a workspace called CMS. Once you have created the workspace, you should add a “base” to it and name it Posts (or whatever you would prefer to call it). Once you have created the base, click on it to enter it.
You can now define this spreadsheet however you wish - you can add additional columns, rename them, and add as much content as you want. As you can see above, I have five fields:
- Title
- Content
- Author
- Featured
- Attachments
Also, keep in mind that I have changed the name of this table to posts instead of Table 1 (I would advise you do something similar as it will make the API nicer to use). If you want to follow along with exactly what I am doing in this tutorial you might want to make your table reflect everything you see above, but you are free to set up your fields however you please.
3. Generate a Read-Only API Key
In order to interact with the Airtable API for the base above, we will need an API key. Since we are creating a simple solution that has no server between our Ionic front-end and our “database” it means that this API key will need to be exposed publicly in our client-side code (there is no way to keep an API key secret if you store it in client-side code).
The problem with exposing our API key is that anybody who finds it might be able to do nefarious things - like change or update our Airtable content when we don’t want them to. To solve this, we will generate a key that is “read only”, meaning that even if someone does find the API key they would only be able to use it to read the content stored in Airtable (which they are allowed to do in this case anyway).
It is important that you do not use the API key for your account. If you generate an API key for your main account it will have ‘Owner’ privileges, meaning it can read/write/update/delete which we definitely do not want. Instead, we will be creating another user. If you click on the SHARE button next to your workspace, it will pop up a screen like this:
What you want to do is invite a new user, but change Creator to Read only. You will then need to sign up using that invite link with a separate email address. This new user will only have permission to read the posts.
Make sure that you are signed in with the Read only account, and then click on the profile icon in the top-right and go to Account > Generate API Key. Make a note of this API key as we will need to use it in the Ionic application.
Now everything is set up for us to access the data stored in Airtable and pull it into an Ionic application if you go to the following link:
and select the base you want to use, you will be able to see tailored documentation on how to use the API. All of the field names and endpoints are updated in the documentation to reflect whatever it is you have named your bases data, which is pretty awesome.
Pulling Data into Ionic
To pull this Airtable data into our Ionic application we will just need to create a simple service. This might vary a little bit depending on exactly what your data looks like and what you want to do with it, but here is an example service that you can use:
import{ Injectable }from'@angular/core';import{ HttpClient }from'@angular/common/http';import{ Observable }from'rxjs';import{ map }from'rxjs/operators';interfaceAirtableResponse{
records: Object[]}
@Injectable({
providedIn:'root'})exportclassPostsService{// NOTE: This API key will be exposed publicly, make sure it is a 'read only' keyprivate apiKey: string ='YOUR-READ-ONLY-API-KEY-HERE';constructor(private http: HttpClient){}publicgetPosts(): Observable<Object[]>{returnthis.http.get('https://api.airtable.com/v0/YOUR-AIRTABLE-BASE/posts?api_key='+this.apiKey).pipe(map((res: AirtableResponse)=> res.records));}publicdeleteTest(){returnthis.http.delete('https://api.airtable.com/v0/YOUR-AIRTABLE-BASE/posts/YOUR-POST-ID?api_key='+this.apiKey);}}
Make sure to replace apiKey
with your own read-only key, and replace the URL in the GET
request with the URL to your own Airtable base (you can grab this URL from the API documentation). The key part here is the getPosts
method that will make a GET
request to the Airtable API - we just need to make sure to send our API key along with the request. I am mapping the response here to modify the data that is sent back so that we only get the records
(which contains our “posts”) instead of all of the data as it makes the data a bit easier to work with, but you don’t need to do this if you don’t want to.
I have also added a deleteTest
method which will use the API to attempt to delete a record. If you want, you can replace this URL with your own Airbase API URL and an id
from one of your posts. The point of this is that when the method is called it should not work - you should get an unauthorized error. If you don’t, it means that your API key isn’t actually read-only.
Displaying the Data in Ionic
With the service in place, we just need to display the data somewhere - here is what I did:
home.page.ts
import{ Component, OnInit }from'@angular/core';import{ PostsService }from'../services/posts.service';
@Component({
selector:'app-home',
templateUrl:'home.page.html',
styleUrls:['home.page.scss'],})exportclassHomePageimplementsOnInit{public posts: Object[]=[];constructor(private postsService: PostsService){}ngOnInit(){// This request should not work, it is just a test to verify that a user with a 'read only' API key is being used.// If a user with different permissions is used, the publicy exposed key can be used by anyone to get full// read/write/update/delete access to your CMSthis.postsService.deleteTest().subscribe((res)=>{
console.log(res);
console.warn("This DELETE request is supposed to fail, its success will mean that your users have full read/write/update/delete permissions");},(err)=>{
console.log("This DELETE request is supposed to fail - if your API key has only 'read only' permissions you may remove this");});// This request will pull in all of the posts from AirTablethis.postsService.getPosts().subscribe((posts)=>{this.posts = posts;
console.log(this.posts);});}}
home.page.html
<ion-header><ion-toolbarcolor="danger"><ion-title>
News & Updates
</ion-title></ion-toolbar></ion-header><ion-contentpadding><ion-card*ngFor="let post of posts"><ion-card-header><ion-card-title>{{post.fields.Title}}</ion-card-title></ion-card-header><ion-card-content><p>By <strong>{{post.fields.Author}}</strong></p><p>{{post.fields.Content}}</p></ion-card-content></ion-card></ion-content>
home.page.scss
ion-content{--ion-background-color:var(--ion-color-danger-tint);}ion-card{border-top: 5px solid var(--ion-color-primary);--background: #fff
}
Summary
The Airtable API is quite extensive, and there is a lot more you could do outside of this simple example. The main point of this tutorial was to demonstrate the purpose and use of a headless CMS, which can make for a pretty simple but powerful backend for Ionic applications. If you are interested in more tutorials on Airtable specifically or perhaps using other platforms as a headless CMS, let me know in the comments!
Displaying Upload/Download Progress in an Ionic Application
A good experience in a mobile application rests heavily on good communication. You communicate your intent to the application, and it communicates back to you. The better you and the application communicate, the better the experience will be. Uploading and downloading files is an operation that can take some time, so it is important that we get the communication right when this is happening.
If you purchase an item online, the experience generally feels better when the seller provides you with a tracking number with detailed information on the progress of the order (e.g. when the order has been processed, when it has been packaged, when it has been shipped, when it has arrived at a depot, and so on). Knowing that the order is proceeding as expected, and being able to estimate how long it will be until the package arrives makes for a much better experience then just ordering something and it showing up a week later.
The same goes for uploads and download. If I’m uploading a large file or files, it is unsettling to just click the Upload button and then wait around for maybe 5 minutes as nothing is visually happening on screen. It would be a much better experience if something is displayed on the screen that indicates the current progress of the upload. In this tutorial, we are going to cover how we can go about reporting upload and download progress in our Ionic applications.
This tutorial will just be focusing on the client-side and assumes that you already have somewhere to upload a file to or download a file from. In a future tutorial I will cover the basics of how to handle uploading and downloading files on a NestJS server.
I will be using an example in this tutorial from one of my own projects that involves uploading multipart/form-data
data containing potentially many different files to a NestJS server (the fact that it is a NestJS server isn’t really relevant).
The code for this upload functionality, before adding the upload/download progress code, looks like this:
upload(data, files){let formData =newFormData();
formData.append('title', data.title);
files.forEach((file)=>{
formData.append('files[]', file.rawFile, file.name);});returnthis.http.post(this.url, formData,{
responseType:'arraybuffer'});}
With the code above, there is no way to tell how much an upload or download has progressed. Instead, the user will just have to wait an indeterminate amount of time until the operation completes. At best, all you can really do here is show some kind of loading indicator. It would be much better to have some kind of progress bar that gives an indication of the time remaining to the user, and that is what we are going to modify this to do.
We won’t actually be covering creating the actual UI element for a progress bar in this tutorial, we are just interested in a number from 0
to 100
that updates to indicate the current progress - you can then use this data to do whatever you like. If you would like more instruction on creating the progress bar itself, I do have a rather old tutorial on creating a custom progress bar component - there will be some minor things that need changing but the basic idea is the same. You could also likely find pre-existing packages that you could install into your project to get a progress bar.
Before We Get Started
Last updated for Ionic 4.0.0
This is an advanced tutorial that assumes you already have a decent working knowledge of the Ionic framework. If you require more introductory level content on Ionic I would recommend checking out my book or the Ionic tutorials on my website.
Reporting Download and Upload Progress
In order to get and process progress events from our uploads or downloads we need to modify our request a little bit. First of all, we are just going to be using a generic HttpRequest
and we will need to supply the reportProgress
option:
let req =newHttpRequest('POST',this.url, formData,{
responseType:'arraybuffer',
reportProgress:true});returnthis.http.request(req).pipe(map(event =>this.getStatusMessage(event)),tap(message => console.log(message)),last());
The responseType
of arraybuffer
is not important here, that is just something I am using in my own code. We then pass that request, to the HttpClient
by calling request
. Typically, we would just return this observable and subscribe to it from wherever we want to trigger it, but in this case we are going to pipe
some operators to interact with the observable. We want to achieve a few things here.
The map
is a commonly used operator which allows for modifying the data emitted by an observable, we are just using it here to modify the response we get into something easier to understand (we will implement getStatusMessage
in a moment). The other two operators we are using are much less common.
The tap
operator will allow us to perform a “side effect” each time some data is emitted from the observable. It is similar to simply subscribing to an observable and running a function, except that it won’t trigger the observable if it isn’t subscribed to elsewhere. In this case, we just want to log out the value every time some value is emitted.
The last
operator will make sure that only the last bit of data from the observable stream will be sent to whatever is subscribed to this observable, and it will contain the final response from the server (e.g. the one we are usually interested in). This allows us to handle all of our progress events from the observable as we wish, and then only the final response will be sent to whatever subscribes to this observable (this way, all of our “progress” events won’t mess up our application logic).
The end result here is that map
will modify the emitted values into something useful, tap
will allow us to log out the values we are receiving (this is just for debugging purposes), and last
will send the final response from the server to whatever subscribed to the observable.
The important part here is getStatusMessage
which is what we are going to use to determine what kind of “progress event” we received, and also to keep track of the current upload/download progress. We are going to set up two behaviour subjects that look like this:
public uploadProgress: BehaviorSubject<number>=newBehaviorSubject<number>(0);public downloadProgress: BehaviorSubject<number>=newBehaviorSubject<number>(0);
You would add these to the top of whatever service you are creating to handle launching your HTTP request. By having these two behaviour subjects, we can update them as new progress data comes in, and anything that is subscribed to these behaviour subjects will immediately get the updated data (e.g. we could tie these values into some kind of progress bar element).
The getStatusMessage
function would look like this:
getStatusMessage(event){let status;switch(event.type){case HttpEventType.Sent:return`Uploading Files`;case HttpEventType.UploadProgress:
status = Math.round(100* event.loaded / event.total);this.uploadProgress.next(status);return`Files are ${status}% uploaded`;case HttpEventType.DownloadProgress:
status = Math.round(100* event.loaded / event.total);this.downloadProgress.next(status);// NOTE: The Content-Length header must be set on the server to calculate thisreturn`Files are ${status}% downloaded`;case HttpEventType.Response:return`Done`;default:return`Something went wrong`}}
We can get a variety of different event types from our request - you can find an explanation of what these event types mean here. We want to figure out which event type we are dealing with, and then handle it accordingly.
In each case, we return a message that our tap
operator is going to log out for us (again, just for debugging purposes in this instance). We are also specifically interested in the UploadProgress
and DownloadProgress
event types. In this case, we use the loaded
and total
values to figure out how much of the upload or download has completed. We then report those values by triggering next
on the behaviour subejcts that we just set up.
An important note about the download progress is that the server must respond with a Content-Length
header for the download, otherwise event.total
will be undefined
and you won’t be able to calculate the progress (we can’t work out how much is left to download if we don’t know how big the download is). If the server does not respond with a Content-Length
header then the total
property will not exist.
Displaying Download and Upload Progress
Now that we have everything in place, we just need to make use of it. You might also want to add an additional function to your service if you intend to handle multiple uploads/downloads in order to reset the progress bar for new batches:
resetProgress(){this.uploadProgress.next(0);this.downloadProgress.next(0);}
To display progress updates, you should just trigger your HTTP request as you usually would by subscribing to it, but you should also subscribe to either the upload or download observables we set up:
this.uploader.resetProgress();this.uploader.uploadProgress.subscribe((progress)=>{this.uploadProgress = progress;})
Each time there is a change in the upload progress we are setting the uploadProgress
member variable to whatever that value is. You can then just bind that value to some kind of progress bar component like the one in the tutorial I mentioned before:
<my-progress-bar[progress]="uploadProgress"></my-progress-bar>
Now, as your files are uploading (or downloading), you will be able to see the progress bar updating as updates come in.
Summary
It might not always be necessary to set up progress events for HTTP requests, especially if they are executed quickly. In fact, you should avoid using reportProgress
if it is not required as it will trigger additional unnecessary change detections if you are not making use of the values. However, if you are handling requests that will result in a significant delay, adding a progress bar can go a long way to improving user experience.
Creating a List Gradient Effect with SASS in Ionic
In this tutorial, we are going to look into how we can use SASS (which is built into Ionic/Angular applications by default) to create a cool gradient effect on our Ionic lists. It will look something like this:
The basic idea is that we choose a base colour, and then that colour will be transitioned dynamically across each list item. This effect can be applied to more than just lists too, you could use it in pretty much any situation where you have a parent element with multiple child elements.
It is straight-forward enough to create a linear gradient effect with CSS using something like this:
background:linear-gradient(#e66465, #9198e5);
But you may notice in the images above that we aren’t just applying a linear gradient. Instead of smoothly changing from one colour to another, each item in the list is actually a solid colour, and each item in the list slowly steps its way towards the end colour.
Each of the images above is actually showing something different too - all of them start with the same base colour but have different modifiers applied. Unlike a gradient where we transition evenly from one colour to another, we are actually modifying the base colour to achieve different effects, from left-to-right we are:
- Modifying the hue. The hue represents the actual “colour” and we are slowly changing this.
- Modifying the brightness. By modifying the brightness we are changing how much white or black is mixed into the colour.
- Modifying the saturation. Saturation represents the “intensity” of a colour, less saturation means more gray is mixed in with the colour.
What we will be discussing in this tutorial are mostly just generic SASS concepts - it is not specific to Ionic or Angular. However, the fact that Ionic uses CSS4 variables does slightly complicate things for us, and we need to do something specific with SASS to account for that.
Before We Get Started
Last updated for Ionic 4.0.0
This tutorial assumes you already have a basic level of understanding of Ionic. If you require more introductory level content on Ionic I would recommend checking out my book or the Ionic tutorials on my website.
I will not be covering what SASS is in this tutorial. If you are not already familiar with SASS then you might find it useful to read about the basics of SASS. If you aren’t interested in learning more about SASS, then it is still possible to complete this tutorial without an appreciation for what SASS is or how it works.
1. Create the Template
We need something to work with so we are just going to create a simple list with Ionic. Your list can look different if you like, and as I mentioned you can even use this effect on something other than a list.
If you would like to follow along with the list I am creating, add the list below to your home.page.html file:
<ion-contentclass="darken"><ion-listlines="none"><ion-item>One</ion-item><ion-item>Two</ion-item><ion-item>Three</ion-item><ion-item>Four</ion-item><ion-item>Five</ion-item><ion-item>Six</ion-item><ion-item>Seven</ion-item><ion-item>Eight</ion-item><ion-item>Nine</ion-item><ion-item>Ten</ion-item></ion-list></ion-content>
Notice that we have given <ion-content>
a class of darken
. This is what we will be using to toggle between the various effects we will be implementing. We are going to start with implementing a “darken” effect that will modify the brightness of the base colour.
2. Using a SASS For-Loop
Since Ionic 4, we now use CSS4 variables like --ion-color-primary
instead of SASS variables like $primary
. Although SASS offers a lot more than just variables, a lot of Ionic developers may only have ever used this feature. This means that a lot of people now probably don’t even use SASS at all in their Ionic 4 projects since we can just use CSS4 variables instead. However, Ionic projects still do use SASS (which is why the style file names end with .scss
and not .css
) and we can utilise the full power that SASS provides if we want to.
One particular thing we can do with SASS is create selectors/styles dynamically by using a for loop. To give you a simple example, instead of creating classes manually like this:
.item-1{width: 50px;}.item-2{width: 100px;}.item-3{width: 150px;}
We could instead create these dynamically like this with SASS:
$start: 1;
$end: 3;@for $i from $start through $end{.item-#{$i}{width: 50px * $i;}}
As you could imagine, this can save a lot of time as your selectors become more complex. This is a contrived example so the value might not be obvious here, but you will soon see how much easier a @for
loop will make our lives when creating our gradient list.
3. Dynamically Modifying Brightness
With a basic understanding of how a for
loop works in SASS, let’s create our own for
loop that will apply our “darken” effect for us.
Add the following code to src/app/home/home.page.scss:
$steps: 10;
$gradient-color: #2ecc71;
$amount: 3;@for $i from 0 through $steps{.darken ion-item:nth-child(#{$i}){--ion-background-color: #{darken($gradient-color, $i * $amount)};}}
There is quite a bit here so let’s unpack it all. First, we declare three variables. The $steps
variable will determine how many items we want to apply this effect on. Since SASS is a pre-processor (meaning it is compiled before our application is run) we can’t supply a dynamic number of items, so we need to know upfront how many items are in our list (or at least, how many items we want to apply the effect on). We will be implementing some additional styles in a moment so that the effect will still display nicely on lists where we don’t know how many items there will be.
The $gradient-color
variable is our base colour that we want to start from and the $amount
variable is used to determine how much we want to change the colour each time, the higher the amount
is the more obvious the effect will be.
Next, we have our for
loop. We loop through from 0
to the number of $steps
we defined. Each time this loop runs it will create additional classes that look like this:
.darken ion-item:nth-child(0){--ion-background-color:/* calculated colour value goes here */}.darken ion-item:nth-child(1){--ion-background-color:/* calculated colour value goes here */}.darken ion-item:nth-child(2){--ion-background-color:/* calculated colour value goes here */}
In this case, classes would be created all the way up to 10
. If you are unfamiliar with the nth-child
pseudo-selector, it will only apply the styles to the child element in the position supplied to nth-child
. So, nth-child(0)
will only apply to the first child, nth-child(1)
will only apply to the second child and so on.
The darken
method is supplied by SASS, and it will calculate the colour for us - we just supply it with our base colour and how much we want to modify it by. By supplying our $i
iterator, we can increase the amount each time we go through the loop. Since we are assigning this value to a CSS4 variable, it is important that we evaluate the result of darken
by surrounding it in #{}
.
This achieves the majority of our effect, but we have two problems:
- The background colour for
<ion-content>
won’t match the list items - If we have more than 10 list items they won’t be styled properly.
Let’s add some additional styles to deal with that.
Add the following styles to src/app/home/home.page.scss:
.darken{--ion-background-color: linear-gradient(#{$gradient-color}, #{darken($gradient-color, $steps * $amount)} 50%);}.darken ion-item{--ion-background-color: #{darken($gradient-color, $steps * $amount)};}
The first selector will target the <ion-content>
component, and we are using a bit of a trick here so that the background colour above the list will be the colour of the first item, and the background color below the list will be the colour of the last item. We are using a linear-gradient
to create a gradient that will transition from the first colour to the last colour (so that the top if one colour, and the bottom is another), but we start the second colour at exactly 50%
so that the colour will change immediately rather than smoothly transitioning. If you were to remove this list and just look at the background, you would see that the top half of the content area would be one flat colour, and the second half of the content area would be another flat colour. This makes it blend in more nicely with the list.
The second selector just provides a default colour for items in our list. If an item does not have a dynamic class created for it (e.g. you use a $steps
variable of 10
but you have 15
items in your list) it will just have a background colour of whatever the last dynamic colour in the list was. This means that once your base colour has finished transitioning to whatever the final colour is, any additional items will just use that colour as well.
4. Dynamically Modify Hue and Saturation
As well as darken
, SASS also provides other methods to modify colour. We want to implement 2 more options to control both the hue and saturation of our list items. We will use the same basic idea, we will just supply a different class selector so that we can control which effect we want to use.
Here is my final home.page.scss file:
ion-item{color: #fff;}
$steps: 10;
$gradient-color: #2ecc71;
$amount: 3;
.darken{--ion-background-color: linear-gradient(#{$gradient-color}, #{darken($gradient-color, $steps * $amount)} 50%);}.darken ion-item{--ion-background-color: #{darken($gradient-color, $steps * $amount)};}@for $i from 0 through $steps{.darken ion-item:nth-child(#{$i}){--ion-background-color: #{darken($gradient-color, $i * $amount)};}}.saturate{--ion-background-color: linear-gradient(#{$gradient-color}, #{saturate($gradient-color, $steps * $amount)} 50%);}.saturate ion-item{--ion-background-color: #{saturate($gradient-color, $steps * $amount)};}@for $i from 0 through $steps{.saturate ion-item:nth-child(#{$i}){--ion-background-color: #{saturate($gradient-color, $i * $amount)};}}.hue{--ion-background-color: linear-gradient(#{$gradient-color}, #{adjust-hue($gradient-color, $steps * 8)} 50%);}.hue ion-item{--ion-background-color: #{adjust-hue($gradient-color, $steps * 8)};}@for $i from 0 through $steps{.hue ion-item:nth-child(#{$i}){--ion-background-color: #{adjust-hue($gradient-color, $i * 8)};}}
Then to toggle between the effects, you can just modify this:
<ion-contentclass="darken">
to this:
<ion-contentclass="hue">
or this:
<ion-contentclass="saturate">
There are also additional methods you can use to modify colours with SASS including:
- alpha
- rgb
- blend
- shade
- contrast
You can find a list of all available color adjusters here.
Summary
We now have an easily configurable visual effect that we can apply to our lists, without needing to manually define 10 or 20 different classes as well as manually picking appropriate colours to achieve the effect.
The “downside” to SASS is that since it is a preprocessor we can’t apply this effect dynamically to a list with any number of items, but we can still create a nice looking effect even if the list has a dynamic number of items.
Deploying a Production NestJS Server on Heroku
I’ve been writing quite a few tutorials on NestJS recently, but an important step in creating a backend for your application is actually hosting it somewhere. During development you can have your NestJS server running over localhost
, but when you are ready to launch your application that server will (usually) need to be accessible to everybody (not just your local machine).
There are many different ways you could go about hosting a NestJS server - you could host it anywhere you are able to host a Node application, whether that’s through a service or just on a server of your own. One particular hosting solution I’ve been continuing to use over the years for Node applications is Heroku. They provide a simple build/deploy process which is particularly nice for Node applications as it will just take a look at your package.json
file, spin up the server for you and install/run your application.
The basic approach to hosting a Node application on Heroku is:
- Have your
package.json
file define any required dependencies (e.g. the dependencies that will already be there) - Specify what node version you want to use in
package.json
- Specify a
start
script inpackage.json
that tells Heroku how to start your application after installing everything - Push your code up to Heroku using
git push heroku master
This process is pretty simple, but things get a little more complicated when we have a complex project structure that includes TypeScript and various other things for development. The only thing we want to actually “host” is the built production files (e.g. what you would find in the dist
folder) not the entire project. So, we need to differentiate between what we are doing with local development and what we are doing when we deploy the application, but we don’t want to mess around with configuring things every time we want to deploy the NestJS server.
Ideally, we want a set up where we can just “set and forget” - we want to be able to work as per normal when we are developing the application, and we want to be able to do a simple commit and push to Heroku when we are ready to deploy. That is what we are going to focus on in this tutorial.
1. Getting the NestJS Project Ready
The example I used for this tutorial was just a blank NestJS starter application, you could do the same by creating a new project:
nest new heroku-test-project
or you could just make these changes to an existing NestJS project. There are a few things we will need to configure in the project before moving on.
Modify src/main.ts to use a dynamic port:
await app.listen(process.env.PORT||3000);
When developing with NestJS we will generally use port 3000
but this won’t work with Heroku. Instead, by adding an optional process.env.PORT
to the listen
method, it will use that port instead if it is defined.
Modify src/main.ts to enable CORS:
asyncfunctionbootstrap(){const app =await NestFactory.create(AppModule);
app.enableCors();await app.listen(process.env.PORT||3000);}
This is optional - you do not need to enable CORS, but if you plan on making cross origin requests to this server (e.g. from an Ionic application to a NestJS server) then you will need to make sure it is enabled.
Create a .gitignore file at the root of your project:
# Specifies intentionally untracked files to ignore when using Git
# http://git-scm.com/docs/gitignore
*~
*.sw[mnpcod]
*.log
*.tmp
*.tmp.*
log.txt
*.sublime-project
*.sublime-workspace
.vscode/
npm-debug.log*
.idea/
.sourcemaps/
.sass-cache/
.tmp/
.versions/
coverage/
www/
dist/
node_modules/
tmp/
temp/
$RECYCLE.BIN/
.DS_Store
Thumbs.db
UserInterfaceState.xcuserstate
We don’t want to push any node_modules
or other junk up to Heroku, so we will make sure that doesn’t get comitted.
2. Modify the package.json File
We can mostly leave the package.json
file as it is, but we do need to make a couple of changes. First, there seems to be a little bug with the start:prod
command which we will be using to start the application on Heroku (instead of the default start
command for development). Instead of pointing to dist/main.js
we need to make sure it points to dist/src/main.js
.
Modify the
start:prod
command inpackage.json
:
"start:prod": "node dist/src/main.js",
Before we can run the start:prod
command we will need to run the prestart:prod
command which actually performs the build and creates our dist
folder with the built files. To do this, we can add a postinstall
script which will automatically run after Heroku has finished installed the dependencies for the project.
Add the following to the
"scripts"
object inpackage.json
:
"postinstall": "npm run prestart:prod",
3. Create Heroku App
Next up, you will need to create your application on Heroku. If you haven’t used Heroku before, you will need to create a new account and you will also need to install the Heroku CLI. Once you have the CLI installed, you will need to run heroku login
to log into your account through the CLI.
Once your application is created on Heroku, it is time to prepare your codebase to be pushed up to it. Assuming that you have not already been using Git for the project, you should initialise a new Git repo with:
git init
You will then need to link it to your Heroku application using the command that was provided to you when you created the app in Heroku. It will look like this:
heroku git:remote -a MY-HEROKU-APP
4. Prepare for Heroku Build
There are a couple of final steps we need to take before we push our code up to Heroku. First, we need to make sure that the environment is configured correctly by running these commands:
heroku config:set NPM_CONFIG_PRODUCTION=false
heroku config:set NODE_ENV=production
The NODE_ENV
config should already be set to production
by default, but we are changing NPM_CONFIG_PRODUCTION
to false
. Since we are performing the TypeScript build on the server (this is what our postinstall
script does) we need all of the devDependencies
in package.json
for it to run properly. If NPM_CONFIG_PRODUCTION
is set to true
the devDependencies
won’t be installed and it won’t work.
Finally, we just need to create a Procfile
. This allows us to specifically supply the command we want to run to start the application rather than the default start
script (which we want to keep for our local development environment).
Create a file called
Procfile
at the root of your project and add the following:
web: npm run start:prod
5. Push Your Code
Now all we need to do is push our code up to Heroku. You can do that by running:
git add .
git commit -m "doing it live"
git push heroku master
Whenever you want to push new code up to Heroku, just add
- commit
- push heroku master
.
Summary
I’m not 100% sure that this process can’t be improved as this isn’t really officially documented - this is just something I’ve played around with that works for me without having to jump through too many hoops. If you have your own process for deploying NestJS applications please feel free to post about it in the comments.
Handling Multiple File Uploads with NestJS
In previous tutorials, we have covered how to POST
data to a NestJS server, but when it comes to uploading files to a NestJS (or any) server, things get just a little bit tricker.
In this tutorial, we are going to walk through setting up a controller in NestJS that can handle receiving file uploads from a front-end application. I will also give a basic example of how to upload from the client-side as well (e.g. sending the files from an Ionic application) to actually make use of the server. In a future tutorial, I will likely create a more in-depth example that includes creating a custom Ionic component that can handle multi-file uploads nicely.
We will need to cover some new NestJS concepts in this tutorial in order to complete it - we will start off by covering those, and then we will get into the code.
Before we Get Started
This tutorial assumes that you already have a basic working knowledge of NestJS. If you do not already feel comfortable with creating a basic NestJS server, creating routes, and POSTing data I would recommend completing these tutorials first:
- An Introduction to NestJS for Ionic Developers
- Using Providers and HTTP Requests in a NestJS Backend
- Sending Data with POST Requests to a NestJS Backend
Most of my tutorials do have a focus on using Ionic with NestJS, but it does not really matter if you aren’t using Ionic on the front-end.
@UseInterceptors, FilesInterceptor, and @UploadedFiles
Before we step through the code required to handle file uploads in a NestJS controller, we are going to talk through some concepts we will be using that I haven’t covered in previous NestJS tutorials.
The @UseInterceptors
decorator allows us to “intercept” and change requests to and from the server. The basic use of our server involves the user making requests from the client-side to our controllers that implement GET
or POST
routes, and the server sending a response back to the user. An “interceptor” sits in the middle of this process and can listen to/modify the messages being sent.
We can create our own interceptors if we wish to do whatever we like, but in this case, we are going to use the built-in FilesInterceptor
which the @nestjs/common
package provides for us. The role of the FilesInterceptor
is to extract files from the incoming request and to provide it to us through the @UploadedFiles
decorator (similarly to how we extract the body of a POST request using @Body and a DTO).
If you were just uploading a single file you would use FileInterceptor
instead of FilesInterceptor
. If you were uploading multiple files with multiple different field names, you could also use the FileFieldsInterceptor
.
Implementing the Controller
Now that we have a little bit of knowledge about these new concepts, let’s see how we would go about implementing them in a controller.
import{ Controller, Post, UseInterceptors, FilesInterceptor, UploadedFiles, Body}from'@nestjs/common';import{ FileDto }from'./dto/file.dto';import*as path from'path';constpngFileFilter=(req, file, callback)=>{let ext = path.extname(file.originalname);if(ext !=='.png'){
req.fileValidationError ='Invalid file type';returncallback(newError('Invalid file type'),false);}returncallback(null,true);}
@Controller('upload')exportclassUploadController{constructor(){}
@Post()
@UseInterceptors(FilesInterceptor('files[]',20,{
fileFilter: pngFileFilter
}))logFiles(@UploadedFiles() images, @Body() fileDto: FileDto){
console.log(images);
console.log(fileDto);return'Done';}}
This controller creates a POST
route at /upload
which will accept up to 20 uploaded .png
files and log out the image data and the body of the POST
request.
As you can see, we are making use of @UseInterceptors
and the FilesInterceptor
here. The first parameter supplied to the interceptor is the field name from the multipart/form-data
sent to the server that contains the array of files (this is so the interceptor knows where to grab the files from, your field name might be different). The second parameter allows us to supply the maximum number of files that can be uploaded, and the third parameter allows us to supply a MulterOptions
object.
NestJS uses the popular multer behind the scenes, and you can configure this using the same options you can with multer regularly. In this case, we are supplying a pngFileFilter
function which will only allow file names with an extension of .png
to be uploaded.
The fileDto
is not important here, this is just regular data being posted to the server along with the files.
Sending Files to the Server
To get files from the client to the server, we will need to POST
the files as multipart/form-data
from a file input to our /upload
route. I already have an example of sending files as multipart/form-data from an Ionic application in a previous tutorial I have written about displaying upload/download progress in Ionic.
However, the basic idea is that you would assemble the file data for sending like this:
// Create a form data objectlet formData =newFormData();// Optional, if you want to use a DTO on your server to grab this data
formData.append('title', data.title);// Append each of the files
files.forEach((file)=>{
formData.append('files[]', file.rawFile, file.name);});
NOTE: The field name we use for the files matches up to the name given to the FilesInterceptor
.
You would then just POST
that formData
object to your NestJS server. Once you do this, you should see the server log out something like the following:
[ { fieldname: 'files[]',
originalname: 'image1.png',
encoding: '7bit',
mimetype: 'image/png',
buffer:
<Buffer 89 50 4e 47 0d 0a 1a 0a 00 00 00 0d 49 48 44 52 00 00 01 52 00 00 02 58 08 02 00 00 00 bf c4 f9 43 00 00 00 09 70 48 59 73 00 00 0b 13 00 00 0b 13 01 ... >,
size: 109693 },
{ fieldname: 'files[]',
originalname: 'image2.png',
encoding: '7bit',
mimetype: 'image/png',
buffer:
<Buffer 89 50 4e 47 0d 0a 1a 0a 00 00 00 0d 49 48 44 52 00 00 01 2c 00 00 00 fa 08 02 00 00 00 94 0f ed a4 00 00 00 09 70 48 59 73 00 00 0b 13 00 00 0b 13 01 ... >,
size: 20538 } ]
As you can see above, I’ve uploaded two files: image1.png
and image2.png
. I could then do whatever I like with these files or their metadata on the server. Perhaps you would want to use the filesystem to store those files somewhere, maybe you want to run some image compression on those images and then return them to the user, or maybe you want to do something else entirely.
Summary
Although there is more to the story than just sending the file data to the server (obviously we are sending it there for some reason, so presumably you’ll want to do something with it), NestJS provides a rather simple implementation for grabbing that data.
As I mentioned earlier, I will likely create another tutorial in the future that focuses on nicely handling multi-file uploads from the client side with Ionic & Angular.
If there is a particular type of tutorial that you would like to see that involves uploading files to a server, let me know in the comments!
Master/Detail Navigation Within a Tabs Layout in Ionic
There have been some reasonably big changes to tab-based navigation in Ionic, and one issue that I see pop up quite a lot is creating more complex navigation within individual tabs. Specifically, a lot of people seem to be running into an issue where after navigating to another page inside of a tab, the tab bar will disappear.
In this tutorial, we will be looking at how to create a multi-level master/detail style navigation pattern inside of a tabs layout. This will allow the tab bar to remain in place as pages within a single tab are being navigated, and the current state of a tab will also be remembered when switching back and forth between tabs.
Before We Get Started
Last updated for Ionic 4.0.0
This tutorial assumes you already have a basic level of understanding of Ionic. If you require more introductory level content on Ionic I would recommend checking out my book or the Ionic tutorials on my website.
If you would like to follow along with this tutorial step-by-step, I will be using the tabs starter template that Ionic provides. You can create a new project based on this template by running the following command:
ionic start tabs-sub-navigation tabs --type=angular
1. Create the Detail Pages
First, we need to create some pages. We are going to create ProductList
and ViewProduct
pages. We will have one of our tabs provide the ability to navigate to a page that would theoretically display a list of products, and then that page will be able to further navigate to a page that displays a specific product.
Run the following commands to create the pages:
ionic g page tab2/ProductList
ionic g page tab2/ViewProduct
It isn’t important that you generate the pages inside of the folder for the tab they will be used in, I think this just helps to keep things organised.
2. Set up the Routes
By default, when you generate a page it will add the routes automatically for you to src/app/app-routing.module.ts. We don’t want that, so make sure to remove those routes.
Make sure that you remove the routes generated for
ProductList
andViewProduct
from src/app/app-routing.module.ts:
import{ NgModule }from'@angular/core';import{ PreloadAllModules, RouterModule, Routes }from'@angular/router';const routes: Routes =[{ path:'', loadChildren:'./tabs/tabs.module#TabsPageModule'}];
@NgModule({
imports:[
RouterModule.forRoot(routes,{ preloadingStrategy: PreloadAllModules })],
exports:[RouterModule]})exportclassAppRoutingModule{}
Instead, we are going to add the routes to our tabs routing module.
Modify the routes in src/app/tabs/tabs.router.module.ts to reflect the following:
const routes: Routes =[{
path:'tabs',
component: TabsPage,
children:[{
path:'tab1',
children:[{
path:'',
loadChildren:'../tab1/tab1.module#Tab1PageModule'}]},{
path:'tab2',
children:[{
path:'',
loadChildren:'../tab2/tab2.module#Tab2PageModule'}]},{ path:'tab2/products', loadChildren:'../tab2/product-list/product-list.module#ProductListPageModule'},{ path:'tab2/products/:id', loadChildren:'../tab2/view-product/view-product.module#ViewProductPageModule'},{
path:'tab3',
children:[{
path:'',
loadChildren:'../tab3/tab3.module#Tab3PageModule'}]},{
path:'',
redirectTo:'/tabs/tab1',
pathMatch:'full'}]},{
path:'',
redirectTo:'/tabs/tab1',
pathMatch:'full'}];
We have left all of the default tabs we had set up unchanged, but we have added in the two routes for our new pages under the routing information for tab2
. Note that these are not being added as children
routes of tab2
, we are just listing them close to the tab2
routing for organisational purposes. Aside from modifying the loadChildren
path to correctly locate the modules for the pages we added, there isn’t anything special about these routes. We have followed a logical URL progression of tab2
->tab2/products
->tab2/products/:id
but that isn’t strictly necessary - you could use whatever you like for the route paths.
3. Implement the Templates
What we have done so far is actually all that is required to set up this style of multi-level tabs navigation. Let’s take a look at implementing the navigation in the templates, though.
Modify src/app/tab2/tab2.page.html to reflect the following:
<ion-header><ion-toolbar><ion-title>
Tab Two
</ion-title></ion-toolbar></ion-header><ion-contentpadding><ion-buttoncolor="primary"routerLink="/tabs/tab2/products"routerDirection="forward">View Products</ion-button></ion-content>
We have just added a simple button that links to the ProductList
page that we created. Now let’s take a look at the navigation in that page.
Modify src/app/tabs2/product-list/product-list.page.html to reflect the following:
<ion-header><ion-toolbar><ion-title>ProductList</ion-title><ion-buttonsslot="start"><ion-back-buttondefaultHref="/tabs/tab2"></ion-back-button></ion-buttons></ion-toolbar></ion-header><ion-contentpadding><ion-buttonrouterLink='/tabs/tab2/products/2'routerDirection="forward">Product Detail</ion-button></ion-content>
Same idea here, except we are linking to a specific product now. We have also added an <ion-back-button>
to the header, and it is important that we supply an appropriate defaultHref
here incase the user refreshes the application directly to this page (which causes the navigation history to be lost). In that case, the defaultHref
will be used when the back button is clicked. If we have a defaultHref
of the root of the application, then the application can get in a state where it is stuck. The back button will link back to the default tab page, but the second tab will still be on the ProductList
page and you won’t be able to get back to the root tab2
page because the back button will always link back to the tab1
page. Providing an appropriate defaultHref
as we have above means we will never get in this situation.
Modify src/app/tabs2/view-product/view-product.page.html to reflect the following:
<ion-header><ion-toolbar><ion-title>ViewProduct</ion-title><ion-buttonsslot="start"><ion-back-buttondefaultHref="/tabs/tab2/products"></ion-back-button></ion-buttons></ion-toolbar></ion-header><ion-contentpadding></ion-content>
We don’t have anything on this page, but again, it is important to make sure to set up that defaultHref
correctly.
Summary
We now have an application where we can navigate within a single tab without breaking the general tabs layout, and each individual tab will also remember its own state/position when navigating between other tabs. The key here is to make sure to define your routes in the routing file for the tabs, not the root routing file for the application.
Animating List Items in Ionic with the Intersection Observer API
In this tutorial, we are going to take a look at creating a directive that will allow us to apply a cool little enter/exit animation to items inside of an Ionic list. We will be able to define a simple CSS class to determine what the animated styles should look like, which might end up looking something like this:
NOTE: The actual end result is smoother than the GIF above looks
In this case, we are modifying the opacity
of the list items and applying a transform
as they enter or exit the list from the top or bottom of the page. You can apply whatever styles you like to this list (just by changing a simple CSS class), but keep in mind that animating anything other than opacity
and transform
will have a much greater negative impact on performance. In some circumstances, the performance may still be fine regardless, but just keep that in mind.
If you don’t know why animating height
is bad for performance and why animating transform
is better for performance, I would recommend watching High Performance Animations in Ionic.
Achieving this animation will rely on adding and removing a particular CSS class as the items come on to the screen (or leave), and to do that we will be using something called the Intersection Observer API.
Before We Get Started
Last updated for Ionic 4.0.0
This tutorial assumes you already have a basic level of understanding of Ionic & Angular. If you require more introductory level content on Ionic I would recommend checking out my book or the Ionic tutorials on my website.
Introducing the Intersection Observer
The Intersection Observer API is a new-ish API and it is the main reason I came up with this tutorial (I’ve wanted an excuse to use the Intersection Observer API for a while now). In short, the Intersection Observer API allows us to easily detect whether an element is currently within the browser’s viewport (i.e. you can see the element on screen). More specifically, the Intersection Observer API allows us to detect/measure the intersection of an element and an ancestor element (with the viewport being the default ancestor if nothing else is specified).
Common examples of using the Intersection Observer API include things like lazy loading images (where we only want to load images as they come on screen), or infinite scrolling (where we only want to load more items as we get towards the bottom of a list).
The basic use of the Intersection Observer API looks like this:
// Create the observerthis.observer =newIntersectionObserver((entries)=>{
entries.forEach((entry: any)=>{if(entry.isIntersecting){// do something if intersecting}else{// do something if not intersecting}})});// Use the observer on specific elementsthis.items.forEach(item =>{this.observer.observe(item.nativeElement);});
We first create a new instance of an IntersectionObserver
which provides a callback function. This function is triggered whenever an element being observed enters or exists the viewport (assuming the viewport is being used). We are provided with an array of entries
which will contain any of these elements.
This provides us with some information about the intersection. We can see if isIntersecting
is true which indicates that the element is visible, but other information is also available like the intersectionRatio
which indicates how much of the element is visible.
Once our observer is set up with a callback, we then tell that observer to observe
any items that we want to watch. In this example, we are watching a list of items. I’d recommend taking a look further into the API, but now that we are armed with some basic knowledge, let’s use that to build our directive.
1. Create an AnimateItems Directive
First, we will create a new directive by running the following command:
ionic g directive directives/AnimateItems
You will need to set this directive up appropriately in your application in order to be able to use it (e.g. by adding it to the module for the page you want to use it on). If you are not aware of how you should include directives/components in your application, I would recommend watching: Using Custom Components on Multiple Pages.
With that directive created, let’s add the code.
Modify src/app/directives/animate-items.directive.ts to reflect the following:
import{ Directive, ContentChildren, QueryList, ElementRef, AfterViewInit, Renderer2 }from'@angular/core';import{ IonItem }from'@ionic/angular';
@Directive({
selector:'[appAnimateItems]'})exportclassAnimateItemsDirectiveimplementsAfterViewInit{private observer: IntersectionObserver;
@ContentChildren(IonItem,{read: ElementRef}) items: QueryList<ElementRef>;constructor(private renderer: Renderer2){}ngAfterViewInit(){this.observer =newIntersectionObserver((entries)=>{
entries.forEach((entry: any)=>{if(!entry.isIntersecting){this.renderer.addClass(entry.target,'exit-enter-styles');}else{this.renderer.removeClass(entry.target,'exit-enter-styles');}})}, threshold:0.5});this.items.forEach(item =>{this.observer.observe(item.nativeElement);});}}
The basic idea is that we want to be able to attach this directive to an <ion-list>
like this:
<ion-listappAnimateItems><ion-item*ngFor="let item of items"></ion-item></ion-list>
and this will handle applying the animation automatically for us. For the animation to work, we need to apply (and remove) a specific CSS class to each of the <ion-item>
elements as they enter or exit the screen. To do this, we need to get a reference to those elements, which we do with @ContentChildren
:
@ContentChildren(IonItem,{read: ElementRef}) items: QueryList<ElementRef>;
Since this directive will be attached to an IonList
, we will be able to grab a reference to all of the IonItem
elements projected inside of that list by using @ContentChildren
. We specifically want a reference to the actual element (not the IonItem
) itself, so we supply read: ElementRef
. This will return us a list of IonItem
elements as a QueryList
which will be stored on the items
member variable.
In the ngAfterViewInit
function we set up our observer. This looks almost identical to the example we just discussed, with a couple of differences. First, we are using the Renderer
to apply or remove a class called exit-enter-styles
based on whether or not the isIntersecting
property is true
or false
.
The other important difference is that we supply a threshold
option to the observer. If the observer is triggered when an element goes off-screen, we can’t really achieve much by animating an item that is already off-screen. Instead, by supplying a threshold
of 0.5
the observer will trigger when an item is either 50%
on the screen or 50%
off the screen (which is the same thing, but I’m talking in the context of entering or exiting the screen). This means that the class will be applied just as the item is about to leave or enter the screen (rather than after it has already left).
2. Define the Styles
Our directive is pretty much done at this point, but we still need to define the styles for the animation. Assuming that you wanted to add an animation to a list on the home page, you might do something like this.
Modify src/app/home/home.page.scss to reflect the following:
ion-item{transition: .3s ease-in-out;}.exit-enter-styles{opacity: 0;transform:translate3d(20px, 0, 0);}
It is important to apply the transition
property so that the changes in CSS styles are animated rather than just being instantly applied. Otherwise, you can just set up whatever kind of styles you want inside of the exit-enter-styles
, but again, make sure you keep performance in mind as I mentioned earlier.
3. Add the Directive to an Ionic List
Finally, we will just need to apply the directive to an Ionic list. If you want to recreate the example I used, just follow these steps.
Modify src/app/home/home.page.ts to reflect the following:
import{ Component }from'@angular/core';
@Component({
selector:'app-home',
templateUrl:'home.page.html',
styleUrls:['home.page.scss'],})exportclassHomePage{public messages;constructor(){this.messages =newArray(100).fill({title:'Hello'});}}
This will create an array with 100
elements prefilled with some dummy data.
Modify src/app/home/home.page.html to reflect the following
<ion-header><ion-toolbarcolor="tertiary"><ion-title>
List Animation
</ion-title></ion-toolbar></ion-header><ion-content><ion-listlines="none"appAnimateItems><ion-item*ngFor="let message of messages"><h2>{{ message.title }}</h2></ion-item></ion-list></ion-content>
Summary
Although the logic for our animation is somewhat complex, we can now easily apply it to any list simply by adding the appAnimateItems
directive to it and defining the styles we want to apply. You could even take this further by making the directive more configurable (e.g. you could add an @Input
to the directive that allows you to specify the name of the class to be applied).
The Intersection Observer API is a fantastic addition to the web, and it transforms the previously difficult and expensive task of detecting intersecting/visible elements and makes it relatively simple.