Onsen UI を使用して、HTML5ハイブリッドアプリを作ってみよう
今回の記事は、Onsen UI blogで2月に公開した"Developing hybrid mobile applications with Onsen UI"の翻訳記事です。
ハイブリッドアプリ開発のお話を、最近はあちらこちらで耳にするよう になりました。プログラム知識が乏しい初心者マークの方、手っ取り早くアプリを開発したい方には、ネイティブアプリ開発のハードルは、高いのが現状です。ネイティブアプリを開発するためには、各プラットフォーム専用のプログラム言語を学び、かつ、開発対象の端末側の機能も学ぶ必要があります。
もちろん、パフォーマンスが良い、端末側のリソースが利用できるなど、ネイティブアプリの開発にも、利点はたくさんあります。
一方、ハイブリッドアプリで使用するテクノロジーは、Web アプリで使用するもの ( HTML、CSS、JavaScript ) と同様であり、プラットフォームには依存しません。Web アプリのテクノロジーに関するノウハウは、膨大で、かつ、簡単に、インターネットから入手できます。
また、ハイブリッドアプリの開発時には、Web 開発で使用していたツールやフレームワークを、そのまま流用できます。jQuery や AngularJS が良い例です。よって、Web アプリ開発者も、ハイブリッドアプリ開発に転向しやすいのではないでしょうか。
ハイブリッドアプリは、ネイティブアプリよりも性能が劣ると、一般的に思われていますが、以前よりもパワフルになった、現在の端末では、ユーザーがその差に気付くことはありません。
ここでは、ハイブリッドアプリのサンプルとして、メモ帳アプリを構築し、併せて、Onsen UIの使用方法も学びます。Onsen UI を使用すれば、洗練されたユーザーインターフェイス ( UI ) を簡単に構築できます。また、アプリの開発には、Monaca クラ ウド IDEを使用します。Monaca クラウド IDE は、Onsen UI をサポートし、かつ、デバッガーも実装されています ( 機能制限なしの無料アカウントを、こちらで作成できます )。Monaca クラウド IDE 上では、アプリの確認・修正を行え、実機上では、アプリの確認を行えます。こちらから、無料で、端末用のデバッガーを入手できます。また、アプリ開発に不慣れな方でも、アプリへのプラグインのインポート、デバッグ、ビルドは、Monaca 側で行ってくれるので安心です。他の IDE を使用する場合には、必要なプラグインを手動で組み込んでください。
Onsen UI と AngularJS
Onsen UI は、HTML5 フレームワークです。Onsen UI を使用すれば、モダンで、見栄えのするユーザーインターフェイスを作成できます。これにより、UI 開発に費やしていた時間を縮小でき、
その分、アプリ本体の性能・機能の充実に、時間を充てることができます。Onsen UI は、AngularJS の使用を前提に設計されていますが、jQuery や他のフレームワークとも併用できます。ここでは、AngularJS を使用しますが、Onsen UI に焦点を当てるため、AngularJS に関しては、込み入った箇所のみ、解説します。
ここで作成するアプリは、メモ帳アプリです。このアプリでは、作業の一覧の保存、作業カテゴリー別のソート、作業詳細の編集・削除を行います。以下の Onsen UI コンポーネントを使用して、アプリを構築しま す。
- ons-navigator
- ons-toolbar
- ons-sliding-menu
- ons-list
- ons-carousel
- ons-template
- ons-popover
これらのコンポーネント ( 要素 ) の使用方法も、順次、解説します。スライディングメニュー表示を行うページを指定したり、または、カルーセルを使用して、複数ある表示用コンテンツをまとめるなど、表示機能のオプションも充実しています。Onsen UI コンポーネントのドキュメントは、こちらでご確認ください。また、こちらも併せてご確認ください。
ここでは、Onsen UI 1.2.1 を使用します。Monacaをご利用の方は、ダッシュボードに表示されている 「 Onsen UI 最小限のテンプレート 」 を開いてください。このテンプレートでは、AngularJS は、ライブラリーとして、デフォルトで組み込まれています。Monaca を利用しない場合には、Onsen UI のドキュメントを確認して、必要なライブラリーを組み込んでください。
最初に、このアプリにおける、AngularJS の取り扱い方法を解説します。AngularJS では、アプリを構成する各ページまたはメインとなる各要素 ( <ons-XXX> ) に、それぞれ、1 個のコントローラーを紐付けます。コントローラーでは、紐付けされたページで使用するスコープ ( Scope ) の定義、および、必要な変数と関数の定義を行います。
たとえば、登録した作業の一覧をメインページに表示する場合、メインページ用のコントローラーを作成して、スコープを定義して、作業データを格納した配列を、そのスコープに代入する処理を行います。また、このコントローラー内に、配列データの編集・削除用の関数なども置けます。しかし、作業別にコントローラーを作成して、処理を行った方が良い場合もあります。このサンプルアプリでは、使用しているページの役割がそれぞれ異なるため、処理を分け、データのみ、コントローラー間で共有します ( 作業の 「 登録 」 ページ、「 詳細表示 」 ページなど、処理は異なりますが、いずれも作業詳細のデータを共有します )。
よって、データ共有の際には、コントローラー間の交信が必要となります。このアプリでは、serviceを 1 個使用して、登録済み作業の保存、および、コントーラー間で共通する処理の制御を行います。このようにすることで、各コントローラーから、必要に応じて、この service を呼び出せます。
登録された作業と作業一覧は、オブジェクトであり、models/Memo.js 内で定義されています。各作業のオブジェクトには、必要な情報 ( 名前、カテゴリー、説明、作成日、進捗状態 ) が格納されます。このような設計 ( model ディレクトリーの設置とオブジェクトの定義 ) になってい ますが、これは、Onsen UI 使用時のお手本ではなく、一例ですので、設計は、開発者が自由に行ってください。サンプルコードは、GitHub から入手できます。
前置きが長くなりました。ここからは、実際にコードを確認してみましょう。
メモ帳アプリのコード
このアプリのコードは、GitHubから入手できます。不明確な点がある場合には、コードの中をご確認ください。また、コードを確認する前に、以下で、アプリを実際に操作してみましょう。
スタイルとデザインは、自由に、カスタマイズできます。
デザインとコード
Onsen UI 要素と起動時のページ
Onsen UI では、メインとなる要素は、1 つに絞ることを推奨します。このメインの要素は、アプリの 「 型 ( pattern ) 」となり、ページ制御に適用・使用されます。
たとえば、ページ遷移の制御を行うなら、スライディングメニュー型、タブバー型などがあります。ここでは、最も頻繁に使用されるナビゲーション型を使用します。この型を使用すると、親子関係がページ間に設定されます ( つまり、スタック内にページが置かれ、それぞれを行き来します )。
よって、ここでは、ons-navigator を、メインの要素として、index.html に置きます。
<ons-navigator var="myNavigator">
<ons-page>
<ons-toolbar fixed-style>
<div class="center">Memo Onsen UI App Example</div>
</ons-toolbar>
<div style="margin: auto; width: 95%;">
<div class="margins">
<span style="color:#666"><ons-icon icon="fa-check-square-o" size="22em"></ons-icon></span>
</div>
</div>
<ons-button modifier="large--cta" onclick="myNavigator.resetToPage('slidingmenu.html')">Start</ons-button>
</ons-page>
</ons-navigator>
var=”myNavigator” を指定して、ナビゲーター ( ons-navigator ) の名前を宣言します。これにより、グローバル変数として、後々、名前で参照できます。ナビゲーター内では、ons-page を使用して、起動時のページを指定します。このページ上では、ons-toolbar を使用して、アプリのタイトルを表示します。
Tip 1 : class=”center” を使用して、タイトルを設定しても、アプリを実行する OS 側が、スタイルを決定します。iOS では、中央揃えとなり、Android では、左揃えとなります。タイトルを中央に固定する場合には、ons-toolbar 内で、fixed-style を設定します。
表示イメージは自由に設定できますが、ここでは、ons-icon を使用して、アイコンを表示します。Onsen UI では、Ioniconsや Fontawesomeのアイコンも使用でき、どの端末上でも支障なく表示できます。アプリ起動時のページで使用する最後の要素は、スタートボタンです。スタートボタンをクリックすると、アプリのメインページに遷移します。ここでは、ボタンに関しては、ons-button を使用して、また、アプリの外見の選択に関しては、modifierを使用します。ボタンのクリック時、ナビゲーターが、ページを遷移させます。ここでは、pushPage( … ) の代わりに、resetToPage( … ) を使用して、前のページをスタックからすべて削除して、slidingmenu.html 内で指定されているページを表示します ( 0 から開始 )。このような処理をすることで、メインページに遷移した後、戻るボタンを押しても、起動時のページに戻ることはありません。
メニュー
メインページを解説します。こちらのメインページ上で、アプリに登録された作業の一覧が表示されます。スライディングメニューをこのページ上に置くので、メインページの親として、このメニューの要素を設定します。slidingmenu.html を作成して、このページ上に、スライディングメニュー本体を設定します。Onsen UI では、テンプレート ( ons-template ) を使用すれば、index.html 内に、別の HTML ページを作成して、配置でき、通常の HTML と同様に動作します。各 HTML のページがコンパクトであれば、1 ページ上にすべてのページが収まります。
<!-- SLIDING MENU -->
<ons-template id="slidingmenu.html">
<ons-page>
<ons-sliding-menu var="slidingMenu" swipeable="false" menu-page="menu.html" main-page="memo.html" side="left" type="overlay" max-slide-distance="200px">
</ons-sliding-menu>
</ons-page>
</ons-template>
ons-sliding-menu を使用して、menu-page=”menu.html” から、メニュー用コンテンツを取得します。また、スライディングメニューは、main-page=”memo.html” 上に表示されます。ここでは、作業一覧に設定されているカルーセルの誤作動を避けるため、swipeable=”false” にします。
menu.html の内容を見てみましょう。
<!-- MENU -->
<ons-template id="menu.html">
<ons-page fixed-style style="background-color: white">
<ons-toolbar fixed-style>
<div class="center">Categories</div>
<div class="right" style="margin-top:5px"><ons-button modifier="quiet" ng-click="slidingMenu.closeMenu()"><ons-icon icon="fa-chevron-left" size="25px" fixed-width="false" style="color: #5087c3"></ons-icon></ons-back-button></div>
</ons-toolbar>
<!-- List of items -->
<ons-list id="categoryList" ng-controller="categoryController">
こちらは、ons-list-item の静的な部分です。
<ons-list-item
modifier="tappable" class="list__item__line-height"
ng-click="setViewRefresh('*'); slidingMenu.closeMenu();">
<i class="ion-home fa-lg" style="color: #666"></i>
All
<span class="item-label">{{countAll}}</span>
</ons-list-item>
<ons-list-item
modifier="tappable" class="list__item__line-height"
ng-click="setViewRefresh('~'); slidingMenu.closeMenu();">
<i class="ion-checkmark" style="color: #666"></i>
Completed
<span class="item-label">{{completedCount ? completedCount : 0}}</span>
</ons-list-item>
<ons-list-header>
<div style="text-align: center;"><ons-icon icon="ion-minus-round"></ons-icon></div>
</ons-list-header>
<ons-list-item
modifier="tappable" class="list__item__line-height"
ng-click="setViewRefresh('=', ' '); slidingMenu.closeMenu();">
<i class="ion-qr-scanner fa-lg" style="color: #666"></i>
No category
<span class="item-label">{{countCategories[' '] ? (countCategories[' '].total - countCategories[' '].completed) : 0}} ({{countCategories[' '] ? countCategories[' '].total : 0}})</span>
</ons-list-item>
こちらは、ons-list-item の動的な部分です ( AngularJS の ng-repeat を使用 )。
<ons-list-item
modifier="tappable" class="list__item__line-height"
ng-click="setViewRefresh('=', item); slidingMenu.closeMenu();" ng-repeat="item in categoryList">
<i class="ion-folder fa-lg" style="color: #666"></i>
{{item}}
<span class="item-label">{{countCategories[item].total - countCategories[item].completed}} ({{countCategories[item].total}})</span>
</ons-list-item>
</ons-list>
</ons-page>
</ons-template>
ons-template を使用しているので、こちらも index.html 内に置けます。また、ons-toolbar を使用して、メニューのタイトルと閉じるボタンを設定しています。このメニューでは、作業に紐付けされたカテゴリーに応じて、該当する作業を一覧化します。
カテゴリー一覧の内容は、ng-controller="categoryController" から取得します。静的な部分には、3 つの固定メニューがあります ( すべて表示、完了した作業を表示、未カテゴリーの作業の表示 )。こちらは、メニューの例なので、自由に編集してください。
固定メニューの表示後に、AngularJS の ng-repeat を使用して ( 最後の ons-list-item を参照 )、ons-list-item を繰り返します。この設定で、すべてのカテゴリーを、メニュー上に表示できます。また、ここでは、各カテゴリーに該当する作業数を表示するため、categoryController から、その情報を取得します。このサンプルでは、countAll と呼ぶカウンター変数を使用して、メモ帳アプリ内に登録されている作業の総数を取得します。この変数は、service ( memoService ) 内に置かれ、コントローラー経由で変更 ( 作業の登録・削除時 ) できます。また、ここでは、categoryController 内に、この変数に対して、リスナーを設定します。これにより、作業数に変更があれば、AngularJS 側で、自動的に値を更新してくれます ( View の更新 )。
$scope.$watch(function() {
return memoService.countRawMemo();
}, function(newValue) {
$scope.countAll = newValue;
}, true);
categoryController の setViewRefresh(...) メソッドを使用して、表示内容を変更します ( View の更新 )。
メインページ
メインページ ( memo.html ) の内容を確認しましょう。ここで、登録済み作業が一覧化されます。
<ons-page ng-controller="memoController">
<ons-toolbar fixed-style ng-controller="slidingMenuController">
<div class="left"><ons-toolbar-button ng-click="slidingMenu.openMenu(); checkSlidingMenuStatus();"><ons-icon icon="bars"></ons-icon></ons-toolbar-button></div>
<div class="center">My tasks</div>
<div class="right">
<ons-button modifier="quiet" onclick="myNavigator.pushPage('additem.html')">
New <ons-icon icon="fa-plus-circle "></ons-icon>
</ons-button>
</div>
</ons-toolbar>
作業を一覧化します。カルーセル表示 ( 各作業の表示時 ) を使用します。
<p style="text-align: center; color: #999; font-size: 14px;">{{category_label}}</p>
<div class="list-action-item" ng-hide="countFiltered">Nothing found</div>
<ons-list>
<ons-list-item modifier="tappable" ng-repeat="item in filteredMemo track by $index">
<ons-carousel var="{{'carousel.id' + $index}}" swipeable style="height: 72px; width: 100%;" initial-index="1" auto-scroll>
カルーセルのインデックス番号 0 には、3 つの処理 ( 3 つのボタン ) を設定します。
<ons-carousel-item class="list-action-menu">
<!-- ACTIONS -->
<div class="main-container">
<div class="fixer-container">
<div class="blockInline">
<ons-button ng-click="deleteItem($index); carousel['id'+$index].setActiveCarouselItemIndex(1);">
Delete
<ons-icon icon="ion-trash-a"></ons-icon>
</ons-button>
</div>
<div class="blockInline">
<ons-button ng-click="carousel['id'+$index].setActiveCarouselItemIndex(1); completeItem($index);" ng-hide="item.completed">
Complete
<ons-icon icon="ion-checkmark-round"></ons-icon>
</ons-button>
</div>
<div class="blockInline">
<ons-button ng-click="setSelected($index); myNavigator.pushPage('itemdetails.html');">
Details
<ons-icon icon="ion-clipboard"></ons-icon>
</ons-button>
</div>
</div>
</div>
</ons-carousel-item>
カルーセルのインデックス番号 1 には、作業詳細の表示設定をします。
<ons-carousel-item class="list-action-item" ng-click="setSelected($index); myNavigator.pushPage('itemdetails.html');">
<div class="name">
{{item.name}} <span class="desc"><ons-icon icon="ion-checkmark-round" ng-show="item.completed"></ons-icon></span>
</div>
<div class="desc">
{{item.date.getFullYear() + "/" + (item.date.getMonth() + 1) + "/" + item.date.getDate()}}
</div>
</ons-carousel-item>
</ons-carousel>
</ons-list-item>
</ons-list>
</ons-page>
このページは、重要で、大きいため、ons-template を使用して、index.html 内に置くのではなく、別個のページを用意します。index.html 内に、まとめて置く場合には、上述したスライディングメニューなど、比較的、短い記述に限定します。いずれの場合でも、ons-toolbar を使用して、
タイトル、新規のページへのリンク ( additems.html の説明は後ほど )、スライディングメニューの表示ボタンを設定します。
Tip 2 : スライディングメニューに設定してある、ちょっとした作り込みをご紹介します。スライディングメ ニューは、表示されているときだけ、スワイプでき、非表示のときは、スワイプ不可になっています。これにより、作業一覧に組み込まれたカルーセルと衝突しません。ここでは、スライディングメニューの動作を変更するため、以下のように、2 つのイベントを用意します。
myApp.controller('slidingMenuController', function($scope){
$scope.slidingMenu.on('postclose', function(){ $scope.slidingMenu.setSwipeable(false); });
$scope.slidingMenu.on('postopen', function(){ $scope.slidingMenu.setSwipeable(true); });
});
また、表示する作業がない場合には、ng-hide を使用して、メッセージを表示します。また、ons-list を動的に使用して、かつ、ons-list 内の item ( ons-list-item ) 毎に、ons-carousel 設定を行って、作業の一覧を表示します。ここでは、ng-repeat を使用して、memoController 側から受け取った、作業を一覧表示します。また、各作業 ( ons-list-item ) には、ons-carousel が設定されています。カルーセルには、インデックス番号 ( 0、1 ) があり、1 には作業詳細、0 には処理ボタン ( ons-carousel-item ) を設定しています。作業一覧の表示時には、インデックス番号 1 の内容が表示され、右方向へスワイプすると、0 の内容が表示されます。
Tip 3 : インデックス番号 0 のボタンの縦位置・横位置は、その数に関わらず、中央揃えになります。
CSS の記述を、以下に記します。
.main-container {
float: left;
position: relative;
left: 50%;
}
.fixer-container{
float: left;
position: relative;
left: -50%;
}
.blockInline{
margin-left: 3px;
margin-right: 3px;
display: inline;
position: relative;
top: 50%;
transform: translateY(-50%);
}
作業一覧から、作業を削除する場合は、memoController 内に記述さ れた、削除用の関数を実行します。また、作業内容の変更や作業完了のマーク付けも、対応する関数を実行して行います。作業完了の処理を行う関数では、作業一覧の再表示時に、完了作業の横に、チェックマークアイコンを表示するように、かつ、インデックス番号 1 の内容を表示するように、「 対応する 」 カルーセルを更新しています。
Tip 4 : カルーセルを更新して、表示を元に戻すには、一覧上のカルーセル毎に、一意の名前を、動的に割り振ります。これにより、後から、その名前を参照でき、また、インデックス番号も更新できます ( よって、上述の 「 対応する 」 カルーセルを更新できます )。ここでは、var="{{'carousel.id' + $index}}" を使用して、carousel.idX 形式で、カルーセルに名前を割り振ります。この X は、ons-list 内の item ( ons-list-item ) のインデックス番号です。静的な方法 ( 例 : var=”myCarousel” ) で、item ( ons-list-item ) の名前を振った場合、ng-repeat 下では、旧 item ( ons-list-item ) の名前がすべて上書きされ、よって、myCarousel を使用して呼び出せるのは、最後の item だけになってしまいます。また、setActiveCarouselItemIndex(1) 関数を呼び出す際には、carousel['id'+$index].setActiveCarouselItemIndex(1); のように、ドット記法ではなく、ブラケット記法を使用します。
作業詳細の表示と変更
作業詳細の表示と変更に関して、解説します。itemdetails.html の内容を見てみましょう。ここでは、コントローラーを 1 つ設定しています。
<ons-page ng-controller="detailsController">
<ons-toolbar fixed-style>
<div class="left"><ons-back-button>Back</ons-back-button></div>
<div class="center"> Task details </div>
<div class="right">
<ons-button modifier="quiet" ng-click="modifyItem();">
Save <ons-icon icon="ion-checkmark"></ons-icon>
</ons-button>
</div>
</ons-toolbar>
<div style="text-align: center">
<ons-list id="itemList">
<ons-list-item class="item">
<ons-row>
<ons-col width="60px">
<div class="item-thum"></div>
</ons-col>
<ons-col>
<header>
<span id="item-name" class="item-name"><input type="text" ng-model="item_name" placeholder="Name" class="text-input text-input--transparent" style="margin-top:8px; width: 100%;"></span>
<span id="item-category" class="item-name"><input type="text" ng-model="item_category" placeholder="Category" class="text-input text-input--transparent" style="margin-top:8px; width: 100%;"></span>
<span id="item-description" class="item-name"><textarea type="text" ng-model="item_description" placeholder="Description" class="textarea textarea--transparent" style="margin-top:8px; width: 100%;"></span>
</header>
</ons-col>
</ons-row>
</ons-list-item>
</ons-list>
</div>
</ons-page>
このページは、myNavigator.pushPage( … ) を使用して、メインページから呼び出されます。myNavigator.pushPage( … ) が行う処理は、ons-navigator 内のスタックへ、新たなページを追加することです。追加後は、myNavigator.popPage() または ons-back-button を使用すれば、スタックに置かれている、前のページに戻れます。
このページ上では、指定された作業の内容を、HTML の input 内に表示します。HTML の input を使用することにより、表示と同時に、変更も行えます。また、各 input には、ng-model を設定 ( 紐付け ) して、コントローラーから新しい値を参照できるようにします。たとえば、ここでは、作業名と ng-model=”item_name” を紐付けして、detailsController 内から、$scope.item_name を使用して、作業名を参照しています。
新規作業の追加とポップアップ表示
新規作業の追加処理は、additem.html で行います。非常に簡単な処理です。前述のように、HTML の input と ng-model の紐付けを行い、addItemController 内で、入力値 を確認した後、memoService の作業一覧へ新規作業を追加します。
また、ここでは、名前用の input が未入力の場合、警告を出し、新規作業の作成を行えないようにします。この処理を行う場合、最初に、index.html 内に、ポップアップ ( ons-popover ) を、以下のように追加します。
<!-- POPOVER -->
<ons-template id="popover.html">
<ons-popover direction="up down" cancelable>
<div style="text-align: center; opacity: 0.5;">
<p>Name input is empty!</p>
<p><small>Enter a name for your task to create or modify it.</small></p>
</div>
</ons-popover>
</ons-template>
次に、addItemController と detailsController のコントローラーから、変更の保存時に、ポップアップを呼び出せるように処理します。
ons.createPopover('popover.html').then(function(popover) {
$scope.popover = popover;
});
$scope.popover.show('#item-name');
最後の行では、HTML 要素 の '#item-name' 上に、ポップアップを表示します。ここでは、input が未入力の場合に、ポップアップを表示しますが、表示のタイミングは、制御する必要があります。
備考
ここで使用したサンプルアプリでは、Onsen UI と AngularJS に焦点を当てたため、window.localStorage、Web SQL、IndexedDB などを使用した、データの保存方法に関しては、触れていません。もちろん、実装できますが、ここでは、固定のサンプルデータを使用して、アプリの動作を即検証できるようにしています。
結論
ここ では、Onsen UI が提供してくれる、使えるユーザーインターフェイスを使用して、簡単なハイブリッドアプリを開発しました。また、開発に役立つ Tip も併記しました。AngularJS の使用は、必須ではありませんが、Onsen UI と相性が良いため、使用した方が効率が良くなります。前述のように、ここで使用したコードは、GitHub上に置いてありますので、自由に、触ってみてください。
以上ですが、開発を簡単にしてくれる、Onsen UI のようなツールがあれば、ハイブリッドアプリの開発も難しくはありません。質問がある方は、このブログにコメントを残すか、スタックオーバーフロー ( onsen-ui タグ下 )に寄稿してください。Onsen UI の使用例に関しては、今後もブログにアップしていく予定です。