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 では、IoniconsFontawesomeのアイコンも使用でき、どの端末上でも支障なく表示できます。アプリ起動時のページで使用する最後の要素は、スタートボタンです。スタートボタンをクリックすると、アプリのメインページに遷移します。ここでは、ボタンに関しては、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>
     &nbsp; 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>
     &nbsp; 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>
     &nbsp; 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>
         &nbsp; {{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 の使用例に関しては、今後もブログにアップしていく予定です。