この記事は Angular Advent Calendar 2016 21日目の記事です。
今年の下半期に angular2-onsenui
という UI フレームワークを作ったので仕様をざっくりと紹介します。
前置きとして、Angular アプリ開発における UI 構築のパターンと、各パターンでの CSS フレームワーク・UI フレームワークとの付き合い方について述べます。
はじめに
Angular は今年9月に安定版(2.0.0
)のリリースを迎え、もはや破壊的変更に右往左往することはなくなりました。加えて、TypeScript、デコレータ、階層的 DI、animations
、Zone.js、RxJS、AoT コンパイル、Angular CLI、サーバサイドレンダリング (SSR) などといった Angular の周辺概念についてもこの半年でずいぶんと日本語の記事が増えました。それらを使いこなすための Tips もまとまってきました。
その気になればどんなコンポーネントでも作れるしどんなアプリでも作れる、という段階に至った人は多いのではないかと思います。
しかし全てをフルスクラッチで実装するのは無謀です。そういった人はライブラリを使って楽をすることを考えるのが次のステップになると思います。来年はその手の話が増えていくのではないかと思います。
ライブラリを使って楽をする対象にはバックエンド(クラウドサービス、オンプレミスサーバ、...)との連携や Flux アーキテクチャの実装などがありますが、この記事では UI の構築を楽にすることについて述べます。
UI の構成要素
UI を構成する要素は多岐に渡りますが、
この記事ではひとまず UI の構成要素を
- スタイル: CSS だけで実装できる部分
- UI ロジック: CSS だけでは実装できない部分(=複雑な視覚効果, UI 状態管理, ...)
の2つに抽象化して考えることにします(どちらもこの記事用の造語なのでご注意ください)。
スタイルと UI ロジックそれぞれに対する要求の違いによって、Angular アプリ開発は以下の4つのパターンに分類できます。
Angular アプリ開発のパターン
パターン1: 「スタイルは自前で用意したい」「UI ロジックも自前で用意したい」
デザインに関して要求の多い、PC サイトの受託開発や自社開発などで多く採用されていると思われるパターンです。
Angular は CSS のカプセル化により冗長な CSS コーディング規約から解放され、比較的簡単にスタイルを自前実装できる上、UI ロジックの自前実装についてもマルチタッチなどの複雑な入力イベントを使わない限りはほぼ困ることが無い程度にフレームワークとして至れり尽くせりなので、Angular の表現力を確かめるのも兼ねてこのパターンを採用している人が多いのではないかと思います。
しかし複雑な UI ロジックの自前実装は車輪の再発明になりがちで、バグの温床にもなります。ある程度複雑な UI ロジックが必要になってきた場合は後述のパターン4に移行することで UI の構築を楽にするのが得策です。
パターン2: 「スタイルはライブラリに頼りたい」「UI ロジックは自前で用意したい」
デザインよりもアプリケーションロジックが重要な PC サイト / モバイルサイトで採用されるパターンです。業務アプリの開発では主にこのパターンが使用されるのではないかと思います。
Angular を使っているとパターン1で述べた理由によりスタイルも全てフルスクラッチで書いてみたくなるところですが、特にスタイルにこだわりが無い場合は CSS フレームワークを利用するのが得策です。CSS に苦手意識を持っている人は是非 styles
を書く手を一旦止めて、CSS フレームワークを利用してみると良いのではないかと思います。
(CSS フレームワークと聞くと Bootstrap を思い浮かべるかもしれませんが、今では他にも色々な選択肢があります。Qiita 内でたくさん紹介されていますので是非チェックしてみてください。)
パターン3: 「スタイルはライブラリに頼りたい」「UI ロジックもライブラリに頼りたい」
モバイルサイト開発や Cordova アプリ開発において、モバイル端末特有の操作(=スワイプ、ピンチイン・アウト、マルチタッチ、etc.)のサポートが必要な場合に選択することになるパターンです。
angular.io のトップページに One framework. Mobile & desktop.
と記載されているように、Angular はモバイルサイトでの利用も想定されています。
しかし、スワイプイベントやマルチタッチイベントは本質的に複雑であり、Angular の力を持ってしても自前での実装は地獄絵図です。例えば、まともなカルーセルやスライディングメニューを実装するには threshold やスワイプの加速度などを考慮して実装を行う必要があります。
また、モバイルブラウザで 60 fps のアニメーションを実現するには dirty hack が少なからず必要になります。
そんなモバイルサイトの開発において、パターン1やパターン2を採用するのは無謀です。
(PC サイトでもある程度複雑な UI ロジックを扱いたい場合はパターン1やパターン2は無謀です)
パターン3では、CSS フレームワークの代わりに UI フレームワークを利用することになります。CSS フレームワークがスタイルのみを提供する一方で、UI フレームワークはスタイルと UI ロジックの両方を提供します。すなわち UI フレームワークを使うと、スタイルと UI ロジックの両方で自前実装の必要がなくなり、楽ができます。
しかし UI フレームワークは CSS フレームワークと異なり、極端に選択肢が少なくなります。特定の JS フレームワークに特化した UI ロジックの提供は、CSS フレームワークに比べて極めてメンテナンスコストが大きいためです。
そんな数少ない Angular 用の UI フレームワークの中の有名どころとしては、PC/モバイル両用の Material Design for Angular 2 、また、PC/モバイル両用だがモバイル寄りの Ionic2 があります。あとは拙作の Onsen UI 2 があります。
そこら辺の話は 8 日目の @rdlabo さんの記事によくまとまっていますので、是非そちらをご覧ください。
【初心者向け】Angular2と一緒に使う有名なサードパーティを見回してみる – Tech Tech Kansai|テクテク関西
http://tec-kansai.tech/technology-2569.php
パターン4: 「スタイルは自前で用意したい」「UI ロジックはライブラリに頼りたい」
スタイルに関してライブラリから制約は受けたくないが、UI ロジックの自前実装はとてもじゃないがやっていられないので UI ロジックに関してはライブラリの恩恵を受けたいという場合に採用されるパターンです。
しかし「スタイルは提供せず UI ロジックのみを提供する」というようなライブラリは今のところ存在しないので、このパターンでは UI フレームワークに同梱されているスタイルを改造・上書きすることになります。
このとき、UI フレームワークの許容範囲を超えた改造を行うとスタイル周りのバグに悩まされることになりますが、それでもパターン1よりは最終的な工数は減ります。
ここまでのまとめ
以上、Angular アプリ開発の4つのパターンを紹介しました。
パターン1を採用している人は是非ともパターン2〜パターン4の採用を検討してみてください。
以下は余談です。
angular2-onsenui
を作りました
今年の下半期に angular2-onsenui
というパッケージを作ったので仕様をざっくりと紹介します(補足: 開発のほとんどは私ではなく @anatoo が行いました)。
angular2-onsenui
は Angular から Onsen UI 2 を利用するためのパッケージです。パターン 3, 4 での利用を想定しています。
作れるもの
こんな感じのものが作れます。
利用方法
npm で onsenui
と angular2-onsenui
をインストールし、OnsenModule
をロードすると利用できます。
詳しい手順については @rdlabo さんが Angular CLI での利用方法をまとめてくれているので省略します。
monaca-cliはやめよう!OnsenUI2を使うにはangular-cliではじめた方がいい理由と使い方 – Tech Tech Kansai|テクテク関西
http://tec-kansai.tech/technology-2584.php
ドキュメントは以下の場所にあります。
使用可能なコンポーネント
OnsenModule
をロードすると下記のセレクタで Onsen UI 2 のコンポーネントを利用できるようになります。
※ 1.0.0-rc.3
時点での仕様です
種別 | セレクタ | コンポーネントクラス *1 | コンポーネントファクトリクラス *2 |
---|---|---|---|
ページマネージャ | ons-navigator |
OnsNavigator |
なし |
ページマネージャ | ons-splitter |
なし *3 | なし |
ページマネージャ | ons-splitter-side |
OnsSplitterSide |
なし |
ページマネージャ | ons-splitter-content |
OnsSplitterContent |
なし |
ページマネージャ | ons-tabbar |
なし | なし |
ページマネージャ | ons-tab |
OnsTab |
なし |
ページ | ons-page |
なし | なし |
ページ | ons-toolbar |
なし | なし |
ページ | ons-toolbar-button |
なし | なし |
ページ | ons-back-button |
なし | なし |
ページ | ons-bottom-toolbar |
なし | なし |
モーダルウィンドウ | ons-modal |
なし | ModalFactory |
ダイアログ | ons-alert-dialog |
なし | AlertDialogFactory |
ダイアログ | ons-dialog |
なし | DialogFactory |
ダイアログ | ons-popover |
なし | PopoverFactory |
アイコン | ons-icon |
なし | なし |
ウィジェット | ons-pull-hook |
OnsPullHook |
なし |
ウィジェット | ons-carousel |
なし | なし |
ウィジェット | ons-carousel-item |
なし | なし |
ウィジェット | ons-list |
なし | なし |
ウィジェット | ons-list-header |
なし | なし |
ウィジェット | ons-list-item |
なし | なし |
ウィジェット | ons-lazy-repeat |
OnsLazyRepeat |
なし |
ウィジェット | ons-input |
OnsInput |
なし |
ウィジェット | ons-button |
なし | |
ウィジェット | ons-range |
OnsRange |
なし |
ウィジェット | ons-switch |
OnsSwitch |
なし |
ウィジェット | ons-fab |
なし | なし |
ウィジェット | ons-speed-dial |
なし | なし |
ウィジェット | ons-speed-dial-item |
なし | なし |
ウィジェット | ons-progress-bar |
なし | なし |
ウィジェット | ons-progress-circular |
なし | なし |
条件分岐 | ons-if |
なし | なし |
グリッドレイアウト | ons-row |
なし | なし |
グリッドレイアウト | ons-col |
なし | なし |
タッチイベント | ons-gesture-detector |
なし | なし |
視覚効果 | ons-ripple |
なし | なし |
*1 厳密には各コンポーネントクラスは @Component
ではなく @Directive
を用いて実装しています。Onsen UI は @Component
の template
ではなく Custom Elements V1 の connectedCallback
, attributeChangedCallback
フックを使ってテンプレートを append しているため、@Component
を使う必要がなかったためです。
*2 モーダルウィンドウやダイアログのような動的に生成する必要のあるコンポーネントにはコンポーネントファクトリを用意しています。
*3 Input, Output もメソッドも不要なコンポーネントに対してはコンポーネントクラスを用意していません。
使い方その1: ウィジェット
ウィジェット系コンポーネントは特に何も考えずにテンプレート内に <ons-button (click)="onClick()">push</ons-button>
等を配置するだけで使えます。
import {
Component,
OnsenModule,
NgModule,
CUSTOM_ELEMENTS_SCHEMA
} from 'angular2-onsenui';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
declare var alert: Function;
@Component({
selector: 'app',
template: `
<ons-button (click)="onClick()">push</ons-button>
`
})
export class AppComponent {
constructor() {
}
onClick() {
alert('Clicked!');
}
}
@NgModule({
imports: [OnsenModule],
declarations: [AppComponent],
bootstrap: [AppComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
class AppModule { }
platformBrowserDynamic().bootstrapModule(AppModule);
動作サンプル (Plunker):
https://plnkr.co/edit/rySiuIg8JF3bOn33Gn0S
使い方その2: ページマネージャ
ページマネージャ系コンポーネントの使い方は若干複雑になります。
以下では ons-navigator
での例を示します。
まず、以下のコードでは DefaultPageComponent
クラスと Page2Component
クラスで「ページ」を定義し、それを ons-navigator
要素の Input に与えることでページを表示しています。
各ページ(実体はコンポーネント)は動的に生成する必要があるため、NgModule の entryComponents
に DefaultPageComponent
クラスと Page2Component
クラスを事前に登録しています。
ページコンポーネントのセレクタには ons-page[page2]
や ons-page[default]
のように属性の指定も行われていますが、これは ons-page
を使用すると複数のページを定義した時にセレクタが衝突し、エラーを生じるためです。
各ページでは、DI で取得した OnsNavigator
インスタンスのメソッドを叩いてページ遷移を行っています。this._navigator.element.popPage();
という文から分かるように、このサンプルは DOM 要素に紐づけられたメソッドを直接叩いていますが、これは内部で使用している Custom Elements V1 の仕様に由来するものです。
import {
Component,
ViewChild,
Params,
OnsNavigator,
OnsenModule,
NgModule,
CUSTOM_ELEMENTS_SCHEMA
} from 'angular2-onsenui';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
@Component({
selector: 'ons-page[page2]',
template: `
<ons-toolbar>
<div class="left"><ons-back-button>Back</ons-back-button></div>
<div class="center">Page2</div>
</ons-toolbar>
<div class="content">
<div style="text-align: center; margin: 10px">
<ons-button (click)="push()">push</ons-button>
<ons-button id="pop" (click)="pop()">pop</ons-button>
</div>
</div>
`
})
export class Page2Component {
constructor(private _navigator: OnsNavigator, private _params: Params) {
}
push() {
this._navigator.element.pushPage(Page2Component, {animation: 'slide');
}
pop() {
this._navigator.element.popPage();
}
}
@Component({
selector: 'ons-page[default]',
template: `
<ons-toolbar>
<div class="center">Page</div>
</ons-toolbar>
<div class="content">
<div style="text-align: center; margin: 10px">
<ons-button id="push" (click)="push(navi)">push</ons-button>
</div>
</div>
`
})
class DefaultPageComponent {
constructor(private _navigator: OnsNavigator) {
}
push() {
this._navigator.element.pushPage(Page2Component);
}
}
@Component({
selector: 'app',
template: `
<ons-navigator animation="slide" [page]="defaultPage"></ons-navigator>
`
})
export class AppComponent {
defaultPage = DefaultPageComponent
}
@NgModule({
imports: [OnsenModule],
declarations: [AppComponent, DefaultPageComponent, Page2Component],
entryComponents: [DefaultPageComponent, Page2Component],
bootstrap: [AppComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
class AppModule { }
platformBrowserDynamic().bootstrapModule(AppModule);
動作サンプル (Plunker):
https://plnkr.co/edit/MoLFcdTRTbD7C7v8A69C
使い方その3: モーダルウィンドウ, ダイアログ
モーダルウィンドウやダイアログのような、アプリのコンポーネントツリー内に属するべきでないコンポーネントは、コンポーネントファクトリを用いて動的に生成して使用します。
以下の例ではダイアログを動的に生成し、ボタン押下をトリガーとしてダイアログを表示しています。
ダイアログの動的生成を行うには、まず ons-dialog
要素をテンプレートに持つ MyDialogComponent
クラスをセレクタ無しで定義し、NgModule の entryComponents
に登録しておきます。次に、DI で取得した DialogFactory
インスタンスの createDialog
メソッドを実行します。すると生成された ons-dialog 要素(+ダイアログコンポーネントを破棄するための関数)が非同期に返ってきます。後は this._dialog.show();
すればダイアログが表示されます。
angular2-onsenui
は DialogFactory
以外にも AlertDialogFactory
, PopoverFactory
, ModalFactory
を提供しています。
コンポーネントファクトリを使用する際は、ngOnDestroy
時に this._destroyDialog();
等でコンポーネントを破壊するのを忘れないでください。
import {
Component,
DialogFactory,
AfterViewInit,
OnInit,
OnDestroy,
Params,
OnsenModule,
NgModule,
CUSTOM_ELEMENTS_SCHEMA
} from 'angular2-onsenui';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
@Component({
template: `
<ons-dialog animation="default" cancelable #dialog>
<div class="dialog-mask"></div>
<div class="dialog">
<div class="dialog-container" style="height: 200px;">
<ons-page>
<ons-toolbar>
<div class="center">Name</div>
</ons-toolbar>
<div class="content">
<div style="text-align: center">
<p>{{message}}</p>
<br>
<ons-button id="close" (click)="dialog.hide()">Close</ons-button>
</div>
</div>
</ons-page>
</div>
</div>
</ons-dialog>
`
})
class MyDialogComponent {
message = '';
constructor(params: Params) {
this.message = <string>params.at('message');
}
}
@Component({
selector: 'app',
template: `
<ons-page class="page">
<ons-toolbar>
<div class="center">Dialog</div>
</ons-toolbar>
<div class="content">
<div style="text-align: center;">
<br>
<ons-button id="open" (click)="show()">Open</ons-button>
</div>
</div>
</ons-page>
`
})
export class AppComponent implements OnInit, OnDestroy {
private _dialog: any;
private _destroyDialog: Function;
constructor(private _dialogFactory: DialogFactory) {
}
ngOnInit() {
this._dialogFactory
.createDialog(MyDialogComponent, {message: 'This is just an example.'})
.then(({dialog, destroy}) => {
this._dialog = dialog;
this._destroyDialog = destroy;
});
}
show() {
if (this._dialog) {
this._dialog.show();
}
}
ngOnDestroy() {
this._destroyDialog();
}
}
@NgModule({
imports: [OnsenModule],
declarations: [AppComponent, MyDialogComponent],
bootstrap: [AppComponent],
entryComponents: [MyDialogComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
class AppModule { }
platformBrowserDynamic().bootstrapModule(AppModule);
動作サンプル (Plunker):
https://plnkr.co/edit/ZAu5BstNGklPIYliVBtt
まとめ
UI の構成要素をスタイルと UI ロジックの2つに分け、それによって定義できる4つの Angular アプリ開発のパターンを紹介しました。
また、拙作のパッケージ angular2-onsenui
の仕様と使い方をざっくりと紹介しました。
余談
Onsen UI の開発目的についてよく聞かれるのでお答えしておくと、Onsen UI は特に直接的な利益に結びつけるために開発しているものではありません(というか OSS 全般がそうだと思います)。
元々自社サービス用に内製していたものをどうせなので OSS として公開した、という感じです。
詳しい経緯については Onsen UI のプロジェクトリーダーの @massie が記事を書いているのでそちらをご覧ください。
Onsen UIが生まれたきっかけ : アシアルブログ
http://blog.asial.co.jp/1490