ngx-onsenui 使ってみた + Tips

#概要

[ngx-onsenui](https://ja.onsen.io/angular2/)はモバイルアプリUIライブラリである[Onsen UI](https://ja.onsen.io/)のAngular[^1]バインディングです。

本記事ではngx-onsenuiを使った感想やTipsを紹介します。

# Onsen UI
Onsen UIは[アシアル株式会社](https://www.asial.co.jp/)が開発するOSSのUIライブラリです。Onsen UIを用いると`<ons-button>`のようなHTMLタグを書くだけでモバイルUIを構築できます。また、iOS/Androidでテーマが自動的に切り替わります。

<img alt="Onsen UI for Angular Kitchensink" src="https://qiita-image-store.s3.amazonaws.com/0/106886/36698dac-97ca-5424-553a-a7d649977ee8.png">
[公式のアレ](https://onsenui.github.io/vue-onsenui-kitchensink/)に触発されて作ったサンプル https://puku0x.github.io/ngx-onsenui-kitchensink/

同様のUIライブラリとして、[Ionic](https://ionicframework.com/)や[F7](https://framework7.io/)などがありますが、Onsen UIはWeb Componentsを採用している[^2]のでAngular/React/Vueと自分の好きなフレームワークを選べるのが特徴です。また、国産OSSなので日本語ドキュメントがあります。

[^1]: 本記事で取り上げるのはAngularJSではなく[Angular](https://angular.io/)です
[^2]: Ionicもv4からWeb Componentsを採用

#ngx-onsenui (Angular + Onsen UI)
フレームワークにAngularを選んだ場合、ngx-onsenuiをインストールすることでOnsen UIコンポーネント（の一部）をAngularディレクティブとして利用することが出来ます。

```
npm install onsenui ngx-onsenui --save
```

ngx-onsenuiを使う場合は、モジュールに`OnsenModule`のインポートと`CUSTOM_ELEMENTS_SCHEMA`の設定が必要です。

```ts:app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { OnsenModule } from 'ngx-onsenui';

import { AppComponent } from './app.component';

@NgModule({
  declarations:    [AppComponent],
  entryComponents: [Page1Component],
  imports:         [BrowserModule, OnsenModule], // ← 追加
  providers:       [],
  bootstrap:       [AppComponent],
  schemas:         [CUSTOM_ELEMENTS_SCHEMA]      // ← 追加
})
export class AppModule { }
```

# Tips
AngularのデータバインディングやTypeScriptによる静的型付けの恩恵を得ることで、アプリ開発の効率化が期待できますが、ngx-onsenuiはまだ開発途中なので思い通りに動かない場合もあります。ここでは開発Tipsとして幾つか紹介します。

## ons.*を使いたい
`onsenui`をインポートしましょう。`ngx-onsenui`ではなく`onsenui`です。

```ts
import { Component } from '@angular/core';
import * as ons from 'onsenui';
//import { onsNotification } from 'ngx-onsenui;  // ons.notificationはこっちでも可

@Component({
  selector: 'ons-page[app-sample]',
  templateUrl: './sample.component.html',
  styleUrls: ['./sample.component.scss']
})
export class SampleComponent {

  confirm(name: string) {
    ons.notification.alert('Hello Onsen UI !');
    //onsNotification.alert('Hello Onsen UI !');
  }

}
```

現在は`onsNotification`と`onsPlatform`が用意されています。

## `selector: 'ons-page[xxx]'`でLintエラーが出ないようにしたい
構文エラーではないのでトランスパイル自体は可能ですが、エディタにエラーが表示され続けるのはあまり良い気はしませんよね。

そんなときは`tslint.json`の`component-selector`に`ons-page`を追加してあげましょう。

```json
{
  "rules": {
    (略)
    "component-selector": [
      true,
      "element",
      ["app", "ons-page"],
      "kebab-case"
    ],
  }
}
```

## 動的にアイコンを変更したい
Angularの属性バインディング(Attribute Binding)を使いましょう。`[icon]="icon名"`や`icon="{{'icon名'}}"`のような使い方はできません。

```html
<ons-icon [attr.icon]="icon"></ons-icon>
```

単純にiOS/Androidで表示を分ける場合は`material`オプションで指定しましょう。

```html
<ons-icon icon="ion-navicon, material: md-menu"></ons-icon>
```

同様に`<ons-tab>`の`icon`属性も`[attr.icon]="icon名"`で指定します。

```html
<ons-tabbar>
  <ons-tab [attr.label]="tab1.label" [attr.icon]="tab1.icon" [page]="tabs1.page" active></ons-tab>
  <ons-tab [attr.label]="tab2.label" [attr.icon]="tab2.icon" [page]="tabs2.page" active></ons-tab>
   <ons-tab [attr.label]="tab3.label" [attr.icon]="tab3.icon" [page]="tabs3.page" active></ons-tab>
</ons-tabbar>
```

※`ons-tab`で`*ngFor`を使うとエラーが出るので↑推奨です（面倒ですが）

## 動的にアイコンの回転を制御したい
属性バインディングを使って、`[attr.spin]`に`null`を代入します。`false`を代入してもアイコンの回転は止まりません。属性ごと消してください。

```html
<ons-icon icon='fa-spinner' [attr.spin]="state === 'action' ? true : null"></ons-icon>
```

## サイドメニューのアニメーションをiOS/Androidで変えたい
iOSでは`reveal`、Androidでは`overlay`と切り替えることで、ネイティブアプリの動きに近くなります。

これを実現するには、まず`ons.platform`で判定して、

```ts
anim = ons.platform.isAndroid() ? 'overlay' : 'reveal';
```

`ons-icon`のときと同じように`attr.animation`で指定します。

```html
<ons-splitter-side swipeable collapse [attr.animation]="anim" [page]="side" ></ons-splitter-side>
```

この例でもそうなのですが、コンポーネントのプロパティバインディングが効かない場合、とりあえず属性バインディングにするとなんとかなります（明確にHTML属性を書き換えるため）。

## サイドメニューを開閉させたい
ngx-onsenuiを使って最初に躓くのがこれだと思います。スワイプだけでなく、メニューボタンを押したらメニューを出すにはどうすれば良いでしょうか。

Angularの作法的には`Service`経由で制御するのが良いと思われます。
https://ja.onsen.io/v2/api/angular2/ons-splitter-side.html

まず、↓のようなサービスを作ります。

```ts
import { Injectable } from '@angular/core';
import { Observable , Subject } from 'rxjs';    // RxJS 6.x
//import { Subject } from 'rxjs/Subject';       // RxJS 5.x
//import { Observable } from 'rxjs/Observable'; // RxJS 5.x

@Injectable()
export class MenuService {
  menu = new Subject();

  get menu$(): Observable<boolean> {
    return this.menu.asObservable();
  }

  toggle() {
    this.menu.next();
  }
}
```

ルートになるページコンポーネントのテンプレートが下記のようになっている場合、

```html:splitter.component.html
<ons-splitter #splitter>
  <ons-splitter-side swipeable collapse [page]="side" ></ons-splitter-side>
  <ons-splitter-content [page]="content"></ons-splitter-content>
</ons-splitter>
```

`content`コンポーネント内で先程の`MenuService`をDIして`toggle()`を呼び、

```ts:content.component.ts
import { Component } from '@angular/core';
import { MenuService } from '../menu.service';

@Component({
  selector: 'ons-page[app-content]',
  templateUrl: './content.component.html',
  styleUrls: ['./content.component.scss']
})
export class ContentComponent implements OnInit, OnDestroy {

  constructor(private menuService: MenuService) { }

  toggleMenu() {
    this.menuService.toggle()
  }
}
```

```html:content.component.html
<ons-toolbar>
  <div class="left">
    <ons-toolbar-button (click)="toggleMenu()>
      <ons-icon icon="ion-navicon, material:md-menu"></ons-icon>
    </ons-toolbar-button>
  </div>
  <div class="center">Content</div>
</ons-toolbar>
```

ルートで`MenuService`の`menu$`を監視し、値が流れてきたら`ons-splitter`の`toggle()`を呼ぶという様に実装すればOKです。

```ts:splitter.component.ts
import { Component, ViewChild, EventEmitter, OnInit, OnDestroy } from '@angular/core';
import { takeUntil } from 'rxjs/operators';

import { MenuService } from '../core/services/app.service';

@Component({
  selector: 'ons-page[app-splitter]',
  templateUrl: './splitter.component.html',
  styleUrls: ['./splitter.component.scss']
})
export class SplitterComponent implements OnInit {
  private readonly onDestroy$ = new EventEmitter();

  @ViewChild('splitter') splitter;

  constructor(private menuService: MenuService) { }

  ngOnInit() {
    this.appService.menu$
      .pipe(takeUntil(this.onDestroy$))
      .subscribe(() => {
        this.splitter.nativeElement.side.toggle();
      });
  }

  ngOnDestroy() {
    this.onDestroy$.emit();
  }
}

```

RxJSによるストリームの処理はAngularを使う以上は避けて通れないので、ここで使い方を覚えておきましょう。

## 無限スクロールがしたい
TwitterのUIのように、「スクロールしたら自動で追加読み込み」を実装したい場合、[公式ドキュメント](https://ja.onsen.io/v2/api/angular2/ons-page.html)には`on-infinite-scroll`属性を使うように書かれてありますが **使えません**。ngx-onsenuiは`on-infinite-scroll`属性に未対応なので、代わりに

```ts:
import { Component, ElementRef, OnInit } from '@angular/core';
import * as ons from 'onsenui';

@Component({
  selector: 'ons-page[app-load-more]',
  templateUrl: './load-more.component.html',
  styleUrls: ['./load-more.component.scss']
})
export class LoadMoreComponent implements OnInit {

  constructor(private element: ElementRef) { }

  // 初期化
  ngOnInit() {
    this.element.nativeElement.onInfiniteScroll = (done) => {
      this.loadMore(done);
    };
  }

  // 追加読み込み
  loadMore(done) {
    // -- 何らかの処理 --

    // 終了
    done();
  }
}
```

↑のように`ElementRef`をDIして、`onInfiniteScroll`にコールバックを設定しましょう。

## `ons-tab`のスワイプ量を知りたい
`ons-tab`は最近スワイプによるタブ切り替えに対応しました。公式のKitchensinkサンプルではスワイプ量に応じてタブの色を変えるという使い方がされています。

スワイプ量を取得するには、`ons-tabbar`にローカル変数を設定して、

```html
<ons-tabbar position="auto" swipeable #tabbar>
  <ons-tab></ons-tab>
  <ons-tab></ons-tab>
  <ons-tab></ons-tab>
</ons-tabbar>
```

`@ViewChild`で取得した`ons-tabbar`の`onSwipe`にコールバックを設定します。

```ts
import { Component, ViewChild, OnInit } from '@angular/core';

@Component({
  selector: 'ons-page[app-tabbar]',
  templateUrl: './tabbar.component.html',
  styleUrls: ['./tabbar.component.scss']
})
export class TabbarComponent implements OnInit {

  @ViewChild('tabbar') tabbar;

  constructor() { }

  ngOnInit() {
    this.tabbar.nativeElement.onSwipe = (index, options) => {
      this.onSwipe(index, options);
    };
  }

  onSwipe(index, options) {
  }

}

```

※ ↑に対する`ons-tabbar`ディレクティブのPR出しました。次回リリースで対応予定です。
https://github.com/OnsenUI/OnsenUI/pull/2452

~~iOSだとコールバックの設定時にアクセス違反で動かない場合もあるようです~~（現在は解消）。

## `ons-pull-hook`を引っ張った量を知りたい

他と同様に`ons-pull-hook`にローカル変数を設定して、

```html
<ons-pull-hook #pullhook></ons-pull-hook>
```

コンポーネント内で`onPull`のコールバックを設定してあげれば良いです。使い方によっては **ratioが1.0を超えるときがある** ので、エラーが出た場合は`ratio`が1.0を超えないように制御するといった対策を取りましょう。

```ts
import { Component, ViewChild, OnInit } from '@angular/core';

@Component({
  selector: 'ons-page[app-pullhook]',
  templateUrl: './pullhook.component.html',
  styleUrls: ['./pullhook.component.scss']
})
export class PullhookComponent implements OnInit {
  ratio = 0;

  @ViewChild('pullhook') pullhook;

  constructor() { }

  onPull(ratio) {
    this.ratio = ratio;
  }

  ngOnInit() {
    this.pullhook.nativeElement.onPull = (ratio, options) => {
      // if (ratio > 1.0) ratio = 1.0;
      this.onPull(ratio);
    };
  }
}
```
`(pull)="onPulle($event)"`のようにイベントバインディング対応してくれるのが一番良いんですけどね。
※ ↑のPRがマージされました。次回リリースで対応予定です。
https://github.com/OnsenUI/OnsenUI/issues/2457

# 感想
去年[Onsen UI 2 + AngularJSで頑張っていた頃](https://qiita.com/puku0x/items/3f2ece43c67742fd67ea)と比較すると、Onsen UIは格段に安定して使いやすくなりました。パフォーマンスについてもAngular/React/Vueといったモダンなフレームワークの採用により気にならないレベルまで向上していると思います。

ngx-onsenuiを使う利点は、Angularのエコシステムの恩恵（モジュールやDI機構による再利用性、CLI連携による開発効率UP）を得られることにあると思います。また、標準でTypeScriptを採用しているため初歩的なバグが発生しにくく、IDEでの補完[^3]が効く所も◎です。

公式は最近人気の[Vue](https://jp.vuejs.org)に力を入れているようですが、Angularも十分魅力的ですので、これを機に`ngx-onsenui`を触ってみるのも良いのではないでしょうか？

拙作のサンプルも幾つか公開していますので、興味のある方はどうぞ。

* [ngx-onsenui-kitchensink](https://puku0x.github.io/ngx-onsenui-kitchensink/) - 公式Kitchensinkサンプルのngx-onsenui版
* [ngx-onsenui-phonegap-starter](https://github.com/puku0x/ngx-onsenui-phonegap-starter) - ngx-onsneui + PhoneGap のサンプル
* [ngx-onsenui-ngrx-todo](https://github.com/puku0x/ngx-onsenui-ngrx-todo) - ngx-onsenui + Redux(@ngrx/store)のサンプル
* [ngx-onsenui-pwa](https://github.com/puku0x/ngx-onsenui-pwa) - ngx-onsenui のPWAサンプル
* [ngx-onsenui on Plunker](https://plnkr.co/edit/Ts1aLXQ1rLMtCAo9sWUI?p=preview) - [Plunker](https://plnkr.co/) のテンプレート
* [ngx-onsenui on StackBlitz](https://stackblitz.com/edit/ngx-onsenui-sample) - [StackBlitz](https://stackblitz.com/) のテンプレート

[^3]: テンプレートの補完には[Angular Language Service](https://marketplace.visualstudio.com/items?itemName=Angular.ng-template)のインストールが必要です