Building Angular 2 Web and Native Apps from a Single Codebase

So, you need to build a web site. Great! If you’re an Angular developer, you immediately dash to your favorite IDE and start scaffolding out your site. But wait! Maybe you also need to build a mobile app to accompany your beautiful new site. Now what?

It’s become clear that Angular 2 is not just a framework that you use to spin up web sites. It’s evolved into a platform that, with its decoupled DOM, fosters cross-platform code reuse. Now developers can repurpose some of the same code that they used to build their web sites and use it to build downloadable mobile apps and even desktop apps. This opens up a whole new world of awesome for the Angular developer.

In this article, I’d like to share with you lessons that I learned by doing this in the real world while making PocketRave.me with the Angular 2 Advanced Seed. This seed project is designed to help you create an app simultaneously for the web and for mobile, sharing code all along. We’ll look at how to use it. So let’s get started!

Getting Started with the Angular 2 Advanced Seed

To build for multiple platforms at once, you need a secret weapon. We will use Nathan Walker’s Angular 2 Advanced Seed. This project began as a fork of Minko Gechev’s Angular Seed, which available for download here. Minko’s seed code is one of the most popular Angular starting templates available, and the Nathan’s advanced seed builds upon it.

advanced-demo

The Advanced Seed in action

Because the advanced seed is very full featured – including i18n multilingual support, unit testing, analytics, and more – you might choose to strip out all the bits and pieces you don’t need. I started by cloning the repo and looking through the package.json files, removing the angulartics and ng2-translate libraries, before installing the dependencies with npm i . Then I scoured the codebase to remove analytics and i18n file dependencies, including the entire i18n folder.

However, to make this process easier for you, I have created a very stripped-down version of this Advanced Seed, which you can find here. I call this seed ‘AMPS’ – for the Angular Multi-Platform Seed. Follow the instructions in the README and visit the documentation hub to start up a sample app on the various platforms you are targeting.

I’d like to just note here that this work stands on the shoulders of the giants who preceded it: Nathan Walker and Minko Gechev. I’m very appreciative of their work!

The ‘AMPS’ project builds on the standard NativeScript ‘hello world’ app which is a ‘tap challenge’:

tap_challenge

Don’t let the simple default stop you from creating something much more awesome, though! PocketRave, for example, built from the Advanced Seed, is much nicer!

pocketrave

The app lets you build a “PocketRave” drawing on your mobile app and view it with the soundtrack of your choice on the web. Your art is turned into a kaleidoscope via CSS transforms and songified via a SoundCloud integration:

pocketrave2

PocketRave also has hardware integration to create a wifi-enabled light show, but that’s a story for another day…

How does it work?

The basic premise of this method of developing for web, mobile, and desktop (if you so wish) while sharing code stems from the advent of Angular 2. When the architectural decision was made to decouple the DOM, developers were given the power to use the same code to build for use cases outside the browser such as a NativeScript app, which produces an installable native mobile app, and/or a desktop app running via Electron.

Once you have cloned the AMPS project and followed the instructions to install it as outlined in the README, open the codebase in an IDE of your choice and take a look at it.

You’ll notice the /dist folder from where code is served after the build process, as well as the standard /node_modules folder. The folders we’ll be particularly involved in are /src and /nativescript . You’ll find code specific to a NativeScript mobile app in the /nativescript folder, such as /App_Resources which contains mobile-specific images and files such as the Info.plist file for iOS.

folder_structure

What can I share?

Code-sharing is enabled in this seed by a combination of symlinks and managed naming conventions. During the installation process, a symlink between the nativescript/app/app folder and src/client/app/ folder is created. You’ll find that any code changes that you make will be reflected in both places for use by the NativeScript app and the web app.

code_sharing

In general, you will be working under the /frameworks folder. For example, I’ve created a /src/client/app/frameworks/sample folder in the AMPS project, and most of my code will go into that folder. In PocketRave, this code is kept in /src/client/app/frameworks/pocketrave.

Out of the box, the seed is set up for optimal code sharing. You will be able to share your routes, your standard TypeScript Component file, and your CSS if needed. However, you will want to differentiate your web site’s HTML markup from your mobile app’s XML markup, as I’ll discuss below. Overall, you will be sharing up to about 75% of your code between the designated platforms.

This architecture allows you to deliver your NativeScript app to the app stores when it’s time, from within the NativeScript folder. There is another symlink in the /assets folder so that you can share any visual assets between the two platforms if needed.

The system of naming conventions in this seed also aids in controlling the code that you share – and don’t share – between platforms. Plain HTML code cannot be shared with a NativeScript codebase, as NativeScript has no DOM to consume that code, so the front end usually will need to be altered for both of these platforms.

The presentation layer of a home page for the web might look like this, with standard semantic-markup and html elements like <div>s and <ul>s:


<header id="header">
        <div class="content">
            <h1>Welcome to PocketRave!</h1>
            <p>Create your own personal sound and light show!<br> Download the mobile app to get started</p>
            <ul class="actions">
                <li><a href="https://itunes.apple.com/us/app/pocketrave-lights-music-art/id1137458667" class="button special">Download for iOS</a></li>
                <li><a href="https://play.google.com/store/apps/details?id=com.ladeezfirstmedia.pocketrave" class="button special">Download for Android</a></li>
            </ul>
        </div>
        <div class="image phone">
            <div class="inner"><img src="../assets/screen.png" alt=""></div>
        </div>
</header>

A presentation tier for a NativeScript app, however, is built differently, using XML markup:

<ActionBar title="PocketRave">
    <ActionItem (tap)="help()" ios.position="right">
        <Button [text]="'fa-question-circle' | fonticon" class="purple fa action-btn"></Button>
    </ActionItem>
</ActionBar>
<StackLayout> 
    <Button (tap)="create()" text="Create a new PocketRave!" class="blue"></Button>  
    <WrapLayout horizontalAlignment="center">
        <StackLayout class="innerCard" width="40%" *ngFor="let rave of (raves$ | async)">
            <StackLayout horizontalAlignment="center">
                <Image [src]="rave.image" height="50"></Image>
                <Label horizontalAlignment="center" textWrap="true" [text]="rave.title"></Label>
            </StackLayout>
        </StackLayout>
    </WrapLayout>
</StackLayout>

How these very different files are handled comes down to the seed’s system of naming conventions and the way the build process is configured to handle files with particular names. Any files that exist only for the web are named as simple .css and .html files (such as the files in PocketRave’s /about folder that are not used on the native mobile app). The /home folder, on the other hand, has implementations for both the web and the native app, so any NativeScript-specific files are designated as such with the addition of tns (for Telerik NativeScript) in their naming convention. Thus home.component.tns.html and home.component.tns.css are the NativeScript home page’s markup and styles. Leveraging the naming conventions of this app allows you to effectively separate the front end in your codebase.

markup_separation

What can I not share?

Sharing may be caring, but some code will just not run on a platform on which it’s not intended to run. It’s easy to leverage naming conventions to fork the front end, but as I mentioned above, we need to share the Angular Component class file (for example about.component.ts). This can present challenges once your app gets complex.

For example, in PocketRave’s Component class below, one immediately notices some funny business in ngOnInit(). What’s all that about Config.IS_MOBILE.NATIVE() and a reference to this.frame?

Well, out-of-the-box the AMPS project supports several methods of protecting your code from non-compatible, platform-specific integrations. One of these methods is the use of some settings in the /utils/config.ts file that is used to specify various platforms (mobile native, web, desktop). Using these flags with conditionals is one method to avoid sharing incompatible code. In this example, it tells the NativeScript app on iOS not to show the default back button on the home page’s ActionBar – never a problem for the web, but a pain on iOS.

import {OnInit, Inject} from '@angular/core';
import {BaseComponent} from '../../../core/decorators/base.component';
import {LogService} from '../../../core/services/log.service';
import {DIALOGS, FRAME} from '../../../core/tokens';
import {FirebaseService} from '../../../pocketrave/services/firebase.service';
import {Observable} from 'rxjs/Observable';
import {Config} from '../../../core/utils/config';
import {Router} from '@angular/router';

@BaseComponent({
  moduleId: module.id,
  selector: 'sd-home',
  templateUrl: 'home.component.html',
  styleUrls: ['home.component.css']
})
export class HomeComponent implements OnInit {
  public raves$: Observable<any>;
  constructor(public firebase: FirebaseService,
              private logger: LogService,
              private _router: Router,
              @Inject(DIALOGS) private dialogs: any,
              @Inject(FRAME) private frame: any
              ) {}

 ngOnInit() {
    this.raves$ = <any>this.firebase.getRaves();
    if (Config.IS_MOBILE_NATIVE()) {
      if (this.frame.topmost().ios) {
          this.frame.topmost().ios.controller.visibleViewController.navigationItem.setHidesBackButtonAnimated(true, false);
      }
    }
  }

A full-featured method to protect your code

A more scalable way to “shield” your platform-specific code is to leverage Angular’s concept of the Opaque Token. According to this article, in Angular 2, we can either use string or type tokens to make dependencies available to the injector. However, when using string tokens, there’s a possibility of running into naming collisions, as the same name might exist for a different provider, especially when using third party libraries. Opaque Tokens solve this problem.

While standard tokens are often simple string primitives, Opaque Tokens are different. They are actual object instances. Using Opaque Tokens allows us to create string-based tokens without running into any collisions. While they were designed to allow developers to protect their code from name collisions, we can use them in this code-shared environment to stop the web and mobile platforms from colliding when certain libraries are not supported, for example, on the web.

Let’s give Opaque Tokens a try!

In the /frameworks/core folder, you’ll find a file called tokens.ts which lists some tokens and stores them in a TOKENS_SHARED constant. For PocketRave, I needed to use many plugins on the NativeScript app that have nothing to do with the web integration, so I listed everything I would use in this file:

import {OpaqueToken} from '@angular/core';

export const FIREBASE: OpaqueToken = new OpaqueToken('firebase');
export const FILE_SYSTEM: OpaqueToken = new OpaqueToken('file-system');
export const ENUMS: OpaqueToken = new OpaqueToken('enums');
export const IMAGE_SOURCE: OpaqueToken = new OpaqueToken('image-source');
export const DIALOGS: OpaqueToken = new OpaqueToken('dialogs');
export const APPSETTINGS: OpaqueToken = new OpaqueToken('appSettings');
export const LOADER: OpaqueToken = new OpaqueToken('LoadingIndicator');
export const COLOR: OpaqueToken = new OpaqueToken('Color');
export const COLORPICKER: OpaqueToken = new OpaqueToken('ColorPicker');
export const AUDIO: OpaqueToken = new OpaqueToken('TNSPlayer');
export const FRAME: OpaqueToken = new OpaqueToken('Frame');
export const SEARCHBAR: OpaqueToken = new OpaqueToken('SearchBar');

export const TOKENS_SHARED: Array<any> = [
  { provide: FIREBASE, useValue: {} },
  { provide: FILE_SYSTEM, useValue: {} },
  { provide: ENUMS, useValue: {} },
  { provide: IMAGE_SOURCE, useValue: {} },
  { provide: DIALOGS, useValue: {} },
  { provide: APPSETTINGS, useValue: {} },
  { provide: LOADER, useValue: {} },
  { provide: COLOR, useValue: {} },
  { provide: COLORPICKER, useValue: {} },
  { provide: AUDIO, useValue: {} },
  { provide: FRAME, useValue: {} },
  { provide: SEARCHBAR, useValue: {} }
];

Then, in separate files (you’ll find them at /src/client/tokens.web.ts and /nativescript/app/tokens.native.ts ), you’ll see a list of those tokens that need to be available for the web, split from those needed for the mobile app. Some plugins can be shared while having different implementations. For example, we use the standard Firebase JavaScript library for its web implementation while on mobile we use the NativeScript Firebase plugin. We hide these two tokens from each other by adding them to separate arrays called TOKENS_WEB and TOKENS_NATIVE:

These are the tokens used on PocketRave.me for the web:

import {FIREBASE, FILE_SYSTEM, ENUMS, IMAGE_SOURCE} from './app/frameworks/core/tokens';
var firebasePlugin = require('firebase');

export const TOKENS_WEB: Array = [
  {
    provide: FIREBASE, useFactory: () => {
      return firebasePlugin.firebase;
    }
  },
  { provide: FILE_SYSTEM, useValue: {} },
  { provide: ENUMS, useValue: {} },
  { provide: IMAGE_SOURCE, useValue: {} }
];

While these are the tokens for PocketRave’s mobile apps:

import {FIREBASE, FILE_SYSTEM, ENUMS, IMAGE_SOURCE, DIALOGS, APPSETTINGS, SEARCHBAR, 
  LOADER, AUDIO, COLOR, COLORPICKER, FRAME} from './app/frameworks/core/tokens';
var firebase = require("nativescript-plugin-firebase");
import * as fs from 'file-system';
import * as enums from 'ui/enums';
import * as imageSource from 'image-source';
import * as dialogs from 'ui/dialogs';
import * as frame from 'ui/frame';
import * as appSettings from 'application-settings';
import { LoadingIndicator } from 'nativescript-loading-indicator';
import { Color } from 'color';
import { ColorPicker } from 'nativescript-color-picker';
import * as audio from 'nativescript-audio';
import * as searchbar from 'ui/search-bar';

export const TOKENS_NATIVE: Array<any> = [
  {
    provide: FIREBASE, useFactory: () => {
      return firebase;
    }
  },
  { provide: FILE_SYSTEM, useValue: fs },
  { provide: ENUMS, useValue: enums },
  { provide: IMAGE_SOURCE, useValue: imageSource },
  { provide: DIALOGS, useValue: dialogs },
  { provide: APPSETTINGS, useValue: appSettings},
  { provide: LOADER, useClass: LoadingIndicator},
  { provide: COLOR, useValue: Color },
  { provide: FRAME, useValue: frame },
  { provide: SEARCHBAR, useValue: searchbar },
  { provide: COLORPICKER, useClass: ColorPicker },
  { provide: AUDIO, useValue: audio }
];

These arrays are injected into the appropriate ngModule for use on the relevant platform:

For the web:

// angular
import { NgModule } from '@angular/core';
import { APP_BASE_HREF } from '@angular/common';
import { BrowserModule } from '@angular/platform-browser';
import { RouterModule } from '@angular/router';
import { Http } from '@angular/http';
...
//pocketrave
import { TOKENS_WEB } from './tokens.web';
import { AppComponent } from './app/frameworks/pocketrave/components/app/app.component';
import { ENTRY_COMPONENTS } from './app/frameworks/pocketrave/components/index';
import { routes } from './app/frameworks/pocketrave/routes';
import { PocketRaveModule } from './app/frameworks/pocketrave/pocketrave.module';

...

@NgModule({
  imports: [
    BrowserModule,
    CoreModule.forRoot([
      { provide: WindowService, useFactory: (win) },
      { provide: ConsoleService, useFactory: (cons) }
    ]),
    routerModule,
    PocketRaveModule.forRoot(TOKENS_WEB)
  ],
  declarations: [
    AppComponent,
    ENTRY_COMPONENTS
  ],
  providers: [
    {
      provide: APP_BASE_HREF,
      useValue: '<%= APP_BASE %>'
    }
  ],
  bootstrap: [AppComponent]
})

export class WebModule { }

And for mobile:

// nativescript
import { NativeScriptModule, platformNativeScriptDynamic, onAfterLivesync, onBeforeLivesync } from 'nativescript-angular/platform';
import { NativeScriptFormsModule } from 'nativescript-angular/forms';
import { NativeScriptHttpModule } from "nativescript-angular/http";
import { NativeScriptRouterModule } from 'nativescript-angular/router';
import { RouterExtensions as TNSRouterExtensions } from 'nativescript-angular/router/router-extensions';
import { NativescriptPlatformLocation } from 'nativescript-angular/router/ns-platform-location';
import { NSLocationStrategy } from 'nativescript-angular/router/ns-location-strategy';

...

// app
import { Config, WindowService, ConsoleService, RouterExtensions } from './app/frameworks/core/index';
import { NSAppComponent } from './pages/app/app.component';
import { TOKENS_NATIVE } from './tokens.native';

...

@NgModule({
  imports: [
    CoreModule.forRoot([
      { provide: WindowService, useClass: WindowNative },
      { provide: ConsoleService, useFactory: (cons) }
    ]),
    AnalyticsModule,
    ComponentsModule,
    PocketRaveModule.forRoot([
      TOKENS_NATIVE,
      {
        provide: TNSFontIconService,
        useFactory: () => {
          return new TNSFontIconService({
            'fa': 'fonts/font-awesome.css',
            'ion': 'fonts/ionicons.css'
          });
        }
      }
    ]),
    NativeScriptRouterModule.forRoot(routes)
  ],
  declarations: [
    NSAppComponent
  ],
  providers: [
    NS_ANALYTICS_PROVIDERS,
    { provide: RouterExtensions, useClass: TNSRouterExtensions }
  ],
  bootstrap: [NSAppComponent]
})

export class NativeModule { }

And finally, the tokens are used in the component file as needed. In this case, we use the FRAME token to specify which mobile platform we are targeting:

import {OnInit, Inject} from '@angular/core';
import {BaseComponent} from '../../../core/decorators/base.component';
import {LogService} from '../../../core/services/log.service';
import {DIALOGS, FRAME} from '../../../core/tokens';
import {FirebaseService} from '../../../pocketrave/services/firebase.service';
import {Observable} from 'rxjs/Observable';
import {Config} from '../../../core/utils/config';
import {Router} from '@angular/router';

@BaseComponent({
  moduleId: module.id,
  selector: 'sd-home',
  templateUrl: 'home.component.html',
  styleUrls: ['home.component.css']
})
export class HomeComponent implements OnInit {
  public raves$: Observable<any>;
  constructor(public firebase: FirebaseService,
              private logger: LogService,
              private _router: Router,
              @Inject(DIALOGS) private dialogs: any,
              @Inject(FRAME) private frame: any
              ) {}

 ngOnInit() {
    this.raves$ = <any>this.firebase.getRaves();
    if (Config.IS_MOBILE_NATIVE()) {
      if (this.frame.topmost().ios) {
          this.frame.topmost().ios.controller.visibleViewController.navigationItem.setHidesBackButtonAnimated(true, false);
      }
    }
  }

Conclusion

I encourage you to clone the AMPS project, install it, and spin up your web and mobile apps. Take a look at the way the folders are set up, observe the naming conventions, and take a look at the use of tokens.

Maybe you’ll build the next cool social media platform, business app, or entirely useless art and music integration like PocketRave. With Angular 2, NativeScript, and these Advanced Seeds, the sky’s the limit! Let me know how it goes in the comments section.

Related resources:

Comments