Fork me on GitHub
Note: The Socially 2 tutorial is no longer maintained. Instead, we have a new, better and comprehensive tutorial, integrated with our WhatsApp clone tutorial: WhatsApp clone tutorial with Ionic 2, Angular 2 and Meteor (we just moved all features and steps, and implemented them better!)

Ionic2

Note: If you skipped ahead to this section, click here to download a zip of the tutorial at this point.

Ionic is a CSS and JavaScript framework. It is highly recommended that before starting this step you will get yourself familiar with its documentation.

In this step we will learn how to add Ionic library into our project, and use its powerful directives to create cross platform mobile (Android & iOS) applications.

We will achieve this by creating separate views for web and for mobile so be creating a separate view for the mobile applications, but we will keep the shared code parts as common code!

Adding Ionic

Using ionic is pretty simple - first, we need to install it:

$ meteor npm install ionic-angular --save

We also have to install missing packages that required by Ionic:

$ meteor npm install @angular/http @angular/platform-server --save

Separate web and mobile things

We are going to have two different NgModules, because of the differences in the imports and declarations between the two platforms (web and Ionic).

We will also separate the main Component that in use.

So let's start with the AppComponent that needed to be change to app.component.web.ts, and it's template that ness to be called app.component.web.html.

Now update the usage of the template in the Component:

23.4 Updated the template import client/imports/app/app.component.web.ts
1
2
3
4
5
6
7
import { Component } from '@angular/core';
 
import style from './app.component.scss';
import template from './app.component.web.html';
import {InjectUser} from "angular2-meteor-accounts-ui";
 
@Component({

And modify the import path in the module file:

23.5 Updated the main component import client/imports/app/app.module.ts
6
7
8
9
10
11
12
import { Ng2PaginationModule } from 'ng2-pagination';
import { AgmCoreModule } from 'angular2-google-maps/core';
 
import { AppComponent } from "./app.component.web";
import { routes, ROUTES_PROVIDERS } from './app.routes';
import { PARTIES_DECLARATIONS } from './parties';
import { SHARED_DECLARATIONS } from './shared';

Now let's take back the code we modified in the previous step (#21) and use only the original version of the Login component, because we do not want to have login in our Ionic version (it will be read only):

23.6 Use web version of Login component in routing client/imports/app/app.routes.ts
5
6
7
8
9
10
11
12
13
14
15
16
import { PartyDetailsComponent } from './parties/party-details.component';
import {SignupComponent} from "./auth/signup.component";
import {RecoverComponent} from "./auth/recover.component";
import {LoginComponent} from "./auth/login.component.web";
 
export const routes: Route[] = [
  { path: '', component: PartiesListComponent },
  { path: 'party/:partyId', component: PartyDetailsComponent, canActivate: ['canActivateForLoggedIn'] },
  { path: 'login', component: LoginComponent },
  { path: 'signup', component: SignupComponent },
  { path: 'recover', component: RecoverComponent }
];

Create a root Component for the mobile, and call it AppMobileComponent:

23.7 Created the main mobile component client/imports/app/mobile/app.component.mobile.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
import {Component} from "@angular/core";
import template from "./app.component.mobile.html";
import {MenuController, Platform, App} from "ionic-angular";
 
@Component({
  selector: "app",
  template
})
export class AppMobileComponent {
  constructor(app: App, platform: Platform, menu: MenuController) {
 
  }
}

And let's create it's view:

23.8 Created the main mobile component view client/imports/app/mobile/app.component.mobile.html
1
<ion-nav [root]="rootPage" swipe-back-enabled="true"></ion-nav>

We used ion-nav which is the navigation bar of Ionic, we also declared that our root page is rootPage which we will add later.

Now let's create an index file for the ionic component declarations:

23.9 Created index file for mobile declarations client/imports/app/mobile/index.ts
1
2
3
4
5
import {AppMobileComponent} from "./app.component.mobile";
 
export const MOBILE_DECLARATIONS = [
  AppMobileComponent
];

Modules Separation

In order to create two different versions of NgModule - one for each platform, we need to identify which platform are we running now - we already know how to do this from the previous step - we will use Meteor.isCordova.

We will have a single NgModule called AppModule, but it's declaration will be different according to the platform.

So we already know how the web module looks like, we just need to understand how mobile module defined when working with Ionic.

First - we need to import IonicModule and declare our root Component there.

We also need to declare IonicApp as our bootstrap Component, and add every Ionic page to the entryComponents.

So let's create it and differ the platform:

23.10 Imported mobile declarations and added conditional main component bootstrap client/imports/app/app.module.ts
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import { MaterialModule } from "@angular/material";
import { AUTH_DECLARATIONS } from "./auth/index";
import { FileDropModule } from "angular2-file-drop";
import { MOBILE_DECLARATIONS } from "./mobile/index";
import { AppMobileComponent } from "./mobile/app.component.mobile";
import { IonicModule, IonicApp } from "ionic-angular";
 
let moduleDefinition;
 
if (Meteor.isCordova) {
  moduleDefinition = {
    imports: [
      IonicModule.forRoot(AppMobileComponent)
    ],
    declarations: [
      ...SHARED_DECLARATIONS,
      ...MOBILE_DECLARATIONS
    ],
    providers: [
    ],
    bootstrap: [
      IonicApp
    ],
    entryComponents: [
      AppMobileComponent
    ]
  }
}
else {
  moduleDefinition = {
    imports: [
      BrowserModule,
      FormsModule,
      ReactiveFormsModule,
      RouterModule.forRoot(routes),
      AccountsModule,
      Ng2PaginationModule,
      AgmCoreModule.forRoot({
        apiKey: 'AIzaSyAWoBdZHCNh5R-hB5S5ZZ2oeoYyfdDgniA'
      }),
      MaterialModule.forRoot(),
      FileDropModule
    ],
    declarations: [
      AppComponent,
      ...PARTIES_DECLARATIONS,
      ...SHARED_DECLARATIONS,
      ...AUTH_DECLARATIONS
    ],
    providers: [
      ...ROUTES_PROVIDERS
    ],
    bootstrap: [
      AppComponent
    ]
  }
}
 
@NgModule(moduleDefinition)
export class AppModule {}

Our next step is to change our selector of the root Component.

As we already know, the root Component of the web platform uses <app> tag as the selector, but in our case the root Component has to be IonicApp that uses <ion-app> tag.

So we need to have the ability to switch <app> to <ion-app> when using mobile platform.

There is a package called ionic-selector we can use in order to get this done, so let's add it:

$ meteor npm install --save ionic-selector

Now let's use in before bootstrapping our module:

23.12 Use ionic-selector package client/main.ts
6
7
8
9
10
11
12
13
14
15
16
17
18
 
import '../both/methods/parties.methods';
 
import ionicSelector from 'ionic-selector';
 
Meteor.startup(() => {
  if (Meteor.isCordova) {
    ionicSelector("app");
  }
 
  const platform = platformBrowserDynamic();
  platform.bootstrapModule(AppModule);
});

What it does? It's changing tag name of the main component (app by default but you can specify any selector you want) to ion-app.

An example:

<body>
  <app class="main"></app>
</body>

will be changed to:

<body>
  <ion-app class="main"></ion-app>
</body>

Ionic styles & icons

Our next step is to load Ionic style and icons (called ionicons) only to the mobile platform.

Start by adding the icons package:

$ meteor npm install --save ionicons

Also, let's create a style file for the mobile and Ionic styles, and load the icons package to it:

23.14 Create ionic.scss and add ionicons to it client/imports/app/mobile/ionic.scss
1
@import "{}/node_modules/ionicons/dist/scss/ionicons";

And let's imports this file into our main styles file:

23.15 Import ionic.scss to main file client/main.scss
1
2
3
4
5
@import '../node_modules/@angular/material/core/theming/all-theme';
@import "imports/app/mobile/ionic.scss";
 
@include md-core();
$app-primary: md-palette($md-light-blue, 500, 100, 700);

Now we need to load Ionic stylesheet into our project - but we need to load it only to the mobile platform, without loading it to the web platform (otherwise, it will override our styles):

23.16 Imported the main css file of ionic client/imports/app/mobile/app.component.mobile.ts
2
3
4
5
6
7
8
9
10
11
import template from "./app.component.mobile.html";
import {MenuController, Platform, App} from "ionic-angular";
 
if (Meteor.isCordova) {
  require("ionic-angular/css/ionic.css");
}
 
@Component({
  selector: "app",
  template

We also need to add some CSS classes in order to get a good result:

23.17 Add two classes to fix an issue with overflow client/main.scss
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
  margin: 0;
}
 
body.mobile {
  overflow: hidden;
}
 
body.web {
  overflow: visible;
  position: initial;
}
 
.sebm-google-map-container {
  width: 450px;
  height: 450px;

And let's add the correct class to the body:

23.18 Set the proper class on body client/main.ts
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
 
import ionicSelector from 'ionic-selector';
 
function setClass(css) {
  if (!document.body.className) {
    document.body.className = "";
  }
  document.body.className += " " + css;
}
 
Meteor.startup(() => {
  if (Meteor.isCordova) {
    ionicSelector("app");
    setClass('mobile');
  }
  else {
    setClass('web');
  }
 
  const platform = platformBrowserDynamic();

We created a mechanism that adds web or mobile class to <body/> element depends on environment.

Share logic between platforms

We want to share the logic of PartiesListComponent without sharing it's styles and template - because we want a different looks between the platforms.

In order to do so, let's take all of the logic we have in PartiesListComponent and take it to an external file that won't contain the Component decorator:

23.19 Take the logic of parties list to external class client/imports/app/shared-components/parties-list.class.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
import {OnDestroy, OnInit} from "@angular/core";
import {Observable, Subscription, Subject} from "rxjs";
import {Party} from "../../../../both/models/party.model";
import {PaginationService} from "ng2-pagination";
import {MeteorObservable} from "meteor-rxjs";
import {Parties} from "../../../../both/collections/parties.collection";
import {Counts} from "meteor/tmeasday:publish-counts";
import {InjectUser} from "angular2-meteor-accounts-ui";
 
interface Pagination {
  limit: number;
  skip: number;
}
 
interface Options extends Pagination {
  [key: string]: any
}
 
@InjectUser('user')
export class PartiesList implements OnInit, OnDestroy {
  parties: Observable<Party[]>;
  partiesSub: Subscription;
  pageSize: Subject<number> = new Subject<number>();
  curPage: Subject<number> = new Subject<number>();
  nameOrder: Subject<number> = new Subject<number>();
  optionsSub: Subscription;
  partiesSize: number = 0;
  autorunSub: Subscription;
  location: Subject<string> = new Subject<string>();
  user: Meteor.User;
  imagesSubs: Subscription;
 
  constructor(private paginationService: PaginationService) {
 
  }
 
  ngOnInit() {
    this.imagesSubs = MeteorObservable.subscribe('images').subscribe();
 
    this.optionsSub = Observable.combineLatest(
      this.pageSize,
      this.curPage,
      this.nameOrder,
      this.location
    ).subscribe(([pageSize, curPage, nameOrder, location]) => {
      const options: Options = {
        limit: pageSize as number,
        skip: ((curPage as number) - 1) * (pageSize as number),
        sort: { name: nameOrder as number }
      };
 
      this.paginationService.setCurrentPage(this.paginationService.defaultId, curPage as number);
 
      if (this.partiesSub) {
        this.partiesSub.unsubscribe();
      }
 
      this.partiesSub = MeteorObservable.subscribe('parties', options, location).subscribe(() => {
        this.parties = Parties.find({}, {
          sort: {
            name: nameOrder
          }
        }).zone();
      });
    });
 
    this.paginationService.register({
      id: this.paginationService.defaultId,
      itemsPerPage: 10,
      currentPage: 1,
      totalItems: this.partiesSize
    });
 
    this.pageSize.next(10);
    this.curPage.next(1);
    this.nameOrder.next(1);
    this.location.next('');
 
    this.autorunSub = MeteorObservable.autorun().subscribe(() => {
      this.partiesSize = Counts.get('numberOfParties');
      this.paginationService.setTotalItems(this.paginationService.defaultId, this.partiesSize);
    });
  }
 
  removeParty(party: Party): void {
    Parties.remove(party._id);
  }
 
  search(value: string): void {
    this.curPage.next(1);
    this.location.next(value);
  }
 
  onPageChanged(page: number): void {
    this.curPage.next(page);
  }
 
  changeSortOrder(nameOrder: string): void {
    this.nameOrder.next(parseInt(nameOrder));
  }
 
  isOwner(party: Party): boolean {
    return this.user && this.user._id === party.owner;
  }
 
  ngOnDestroy() {
    this.partiesSub.unsubscribe();
    this.optionsSub.unsubscribe();
    this.autorunSub.unsubscribe();
    this.imagesSubs.unsubscribe();
  }
}

And let's clean up the PartiesListComponent, and use the new class PartiesList as base class for this Component:

23.20 Create a clean parties list for web display client/imports/app/parties/parties-list.component.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Component } from '@angular/core';
import { PaginationService } from 'ng2-pagination';
import { PartiesList } from "../shared-components/parties-list.class";
 
import template from './parties-list.component.html';
import style from './parties-list.component.scss';
 
@Component({
  selector: 'parties-list',
  template,
  styles: [ style ]
})
export class PartiesListComponent extends PartiesList {
  constructor(paginationService: PaginationService) {
    super(paginationService);
  }
}

Now let's create a basic view and layout for the mobile platform, be creating a new Component called PartiesListMobile, starting with the view:

23.21 Create a basic view of the mobile version client/imports/app/mobile/parties-list.component.mobile.html
1
2
3
4
5
6
7
8
<ion-header>
  <ion-navbar>
    <ion-title>Socially</ion-title>
  </ion-navbar>
</ion-header>
<ion-content>
  Parties!
</ion-content>

And it's Component, which is very similar to the web version, only it uses different template:

23.22 Create the mobile version of PartiesList component client/imports/app/mobile/parties-list.component.mobile.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { Component } from '@angular/core';
import { PaginationService } from 'ng2-pagination';
import { PartiesList } from "../shared-components/parties-list.class";
 
import template from './parties-list.component.mobile.html';
 
@Component({
  selector: 'parties-list',
  template
})
export class PartiesListMobileComponent extends PartiesList {
  constructor(paginationService: PaginationService) {
    super(paginationService);
  }
}

Now let's add the mobile Component of the parties list to the index file:

23.23 Added PartiesListMobile component to the index file client/imports/app/mobile/index.ts
1
2
3
4
5
6
7
import {AppMobileComponent} from "./app.component.mobile";
import {PartiesListMobileComponent} from "./parties-list.component.mobile";
 
export const MOBILE_DECLARATIONS = [
  AppMobileComponent,
  PartiesListMobileComponent
];

And let's add the Component we just created as rootPage for our Ionic application:

1
2
3
4
5
6
7
 
12
13
14
15
16
17
18
19
20
import {Component} from "@angular/core";
import template from "./app.component.mobile.html";
import {MenuController, Platform, App} from "ionic-angular";
import {PartiesListMobileComponent} from "./parties-list.component.mobile";
 
if (Meteor.isCordova) {
  require("ionic-angular/css/ionic.css");
...some lines skipped...
  template
})
export class AppMobileComponent {
  rootPage: any;
 
  constructor(app: App, platform: Platform, menu: MenuController) {
    this.rootPage = PartiesListMobileComponent;
  }
}

Now we just need declare this Component as entryComponents in the NgModule definition, and make sure we have all the required external modules in the NgModule that loaded for the mobile:

23.25 Update the module imports and entry point client/imports/app/app.module.ts
16
17
18
19
20
21
22
23
24
25
26
27
28
29
 
36
37
38
39
40
41
42
import { MOBILE_DECLARATIONS } from "./mobile/index";
import { AppMobileComponent } from "./mobile/app.component.mobile";
import { IonicModule, IonicApp } from "ionic-angular";
import { PartiesListMobileComponent } from "./mobile/parties-list.component.mobile";
 
let moduleDefinition;
 
if (Meteor.isCordova) {
  moduleDefinition = {
    imports: [
      Ng2PaginationModule,
      IonicModule.forRoot(AppMobileComponent)
    ],
    declarations: [
...some lines skipped...
      IonicApp
    ],
    entryComponents: [
      PartiesListMobileComponent
    ]
  }
}

Now we want to add the actual view to the mobile Component, so let's do it:

23.26 Add name, description ad RSVPs to the view client/imports/app/mobile/parties-list.component.mobile.html
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
  </ion-navbar>
</ion-header>
<ion-content>
  <ion-card *ngFor="let party of parties | async">
    <ion-card-content>
      <ion-card-title>
        {{party.name}}
      </ion-card-title>
      <p>
        {{party.description}}
      </p>
    </ion-card-content>
 
    <ion-row no-padding>
      <ion-col text-right>
        <ion-badge>
          yes {{party | rsvp:'yes'}}
        </ion-badge>
        <ion-badge item-center dark>
          maybe {{party | rsvp:'maybe'}}
        </ion-badge>
        <ion-badge item-center danger>
          no {{party | rsvp:'no'}}
        </ion-badge>
      </ion-col>
    </ion-row>
  </ion-card>
</ion-content>

We used ion-card which is an Ionic Component.

And in order to have the ability to load images in the mobile platform, we need to add some logic to the displayMainImage Pipe, because Meteor's absolute URL is not the same in mobile:

23.27 Fix an issuewith a absolute path of an image client/imports/app/shared/display-main-image.pipe.ts
1
2
3
4
5
6
7
 
18
19
20
21
22
23
24
25
26
27
28
29
import {Pipe, PipeTransform} from '@angular/core';
import { Images } from '../../../../both/collections/images.collection';
import { Party } from '../../../../both/models/party.model';
import { Meteor } from "meteor/meteor";
 
@Pipe({
  name: 'displayMainImage'
...some lines skipped...
    const found = Images.findOne(imageId);
 
    if (found) {
      if (!Meteor.isCordova) {
        imageUrl = found.url;
      } else {
        const path = `ufs/${found.store}/${found._id}/${found.name}`;
        imageUrl = Meteor.absoluteUrl(path);
      }
    }
 
    return imageUrl;

And let's add the image to the view:

5
6
7
8
9
10
11
</ion-header>
<ion-content>
  <ion-card *ngFor="let party of parties | async">
    <img *ngIf="party.images" [src]="party | displayMainImage">
    <ion-card-content>
      <ion-card-title>
        {{party.name}}

Fixing fonts

As you probably notice, there are many warnings about missing fonts. We can easily fix it with the help of a package called mys:fonts.

$ meteor add mys:fonts

That plugin needs to know which font we want to use and where it should be available.

Configuration is pretty easy, you will catch it by just looking on an example:

23.30 Define fonts.json fonts.json
1
2
3
4
5
6
7
8
{
  "map": {
    "node_modules/ionic-angular/fonts/roboto-medium.ttf": "fonts/roboto-medium.ttf",
    "node_modules/ionic-angular/fonts/roboto-regular.ttf": "fonts/roboto-regular.ttf",
    "node_modules/ionic-angular/fonts/roboto-medium.woff": "fonts/roboto-medium.woff",
    "node_modules/ionic-angular/fonts/roboto-regular.woff": "fonts/roboto-regular.woff"
  }
}

Now roboto-regular.ttf is available under http://localhost:3000/fonts/roboto-regular.ttf.

And... You have an app that works with Ionic!

Summary

In this tutorial we showed how to use Ionic and how to separate the whole view for both, web and mobile.

We also learned how to share component between platforms, and change the view only!

We also used Ionic directives in order to provide user-experience of mobile platform instead of regular responsive layout of website.