[Onsen UI + Vue] Navigator Outside Tabbar

Onsen UI Playgroundで紹介されているNavigator Outside Tabbarを単一ファイルコンポーネントで作ってみました。画面をスタック管理するナビゲーションと画面表示を切り替えるタブバーを併用したサンプルになります。

親コンポーネントとなるナビゲーションのページスタックを操作することになりますが、今回はOnsen UI + Vueの勉強がてら(※)ページスタックをそれぞれの子コンポーネントで直接操作する方法と、ページスタックを親コンポーネントでのみ操作する方法で作成してみました。ほかにもページスタックをグローバル変数(Vue.prototype.$pageStack)にする方法やVuexを使う方法を考えてみましたがVueの基礎となるコンポーネント間の通信を素直に行う前述の2パターンで作ってみることにしました。

※ 私は最近Vue.jsの書籍を一冊読み終えOnsen UI + Vueも今回はじめて触った初心者ですので解釈やソースコードに間違いがあるかもしれません。その点をご留意ください。

目次

バージョン情報

  • onsenui@2.10.3
  • vue-onsenui@2.6.1
  • vue@2.5.16

コンポーネント構成

アプリ起動時のコンポーネント構成は以下のようになります。ナビゲーション(VOnsNavigator)の子コンポーネントにページスタックに格納されたページ(AppTabbar)が配置されます。このページで使われるタブバー(VOnsTabbar)は3つのボタンと3つのページで構成されます。

HomeページのPush Pageボタンを4回押すと以下のようになります。pageStack配列と VOnsNavigatorの子コンポーネントに4つのPageNav1コンポーネント(オブジェクト)が追加されています。画面にはpageStack配列の最後に格納されたページ(title: “1-4″)を表示します。

ファイル構成

main.js
Vueインスタンスの作成
App.vueナビゲーション
AppTabbar.vueタブバー
Home.vueタブバーの左側のページ
Cards.vue タブバーの中央のページ
Settings.vue タブバーの右側のページ
PageNav1.vueHomeからプッシュするページ
PageNav2.vue Cardsからプッシュするページ

ソースコード

ページスタックを子コンポーネントで直接操作する方法

ナビゲーションのページスタックをpropsでそれぞれの子コンポーネントに渡し、子コンポーネントで直接ページスタックにプッシュやポップを行います。

main.js

Vueインスタンスを作成します。templateオプションにより#app要素(www/index.html)をAppコンポーネントで置き換えています。また、createdフックでandroid用のUIを適用しています。

import 'onsenui';
import Vue from 'vue';
import VueOnsen from 'vue-onsenui';

// Onsen UI Styling and Icons
require('onsenui/css-components-src/src/onsen-css-components.css');
require('onsenui/css/onsenui.css');

import App from './App.vue';

Vue.use(VueOnsen);

new Vue({
  el: '#app',
  template: '<app></app>',
  components: { App },
  created () {
    this.$ons.platform.select('android')
  }
});
App.vue

Onsen UIのナビゲーションを配置してページのスタック管理を行います。カスタム要素(component)を使用しているのはpageStackを子コンポーネントに渡すためです。

カスタム要素のis属性についてはVue公式サイトで解説されています。

<template>
  <v-ons-navigator :page-stack="pageStack">
    <component
      v-for="page in pageStack"
      :is="page"
      :key="page.key"
      :page-stack="pageStack">
    </component>
  </v-ons-navigator>
</template>

<script>
import AppTabbar from './AppTabbar'

export default {
  data () {
    return {
      pageStack: [AppTabbar]
    }
  }
}
</script>
AppTabbar.vue

ナビゲーションの先頭にスタックされるページです。ツールバーとタブバーで構成されます。

タブバーによりHome/Cards/Settingsのページとボタンが作成されます。表示するページはindex属性で指定します。ボタンを押すとindexの値が更新され、対応するページを表示します。

ここでもAppコンポーネントと同様にpageStackを子コンポーネントに渡します。タブバーのスロット(※)とカスタム要素(component)を使用しています。

※ slot = “pages” の記述についてはOnsen UIガイドのv-ons-tabbarの [実例] – [Tabs]で解説されています。

pageStackにPageNav1やPageNav2がプッシュされると、このページ全体が隠れるため、ツールバーとタブバーのボタンも一緒に隠れることになります。アプリによってはツールバーやタブバーを残したい場合もあると思います。このやり方についてはMonaca公式のモナカプレスで詳しく解説されています。

<template>
  <v-ons-page>
    <v-ons-toolbar>
      <div class="center">Navigator+Tabbar</div>
    </v-ons-toolbar>

    <v-ons-tabbar
      position="auto"
      :tabs="tabs"
      :index.sync="activeIndex"
    >
      <template slot="pages">
        <component
          v-for="tab in tabs"
          :is="tab.page"
          :key="tab.label"
          :page-stack="pageStack">
        </component>
      </template>

    </v-ons-tabbar>
  </v-ons-page>
</template>

<script>
import Home from './Home'
import Cards from './Cards'
import Settings from './Settings'

export default {
  key: 'AppTabbar',
  data () {
    return {
      activeIndex: 0,
      tabs: [
        {
          icon: 'ion-home',
          label: 'Home',
          page: Home
        },
        {
          icon: 'ion-card',
          label: 'Cards',
          page: Cards
        },
        {
          icon: 'ion-ios-cog',
          label: 'Settings',
          page: Settings,
        }
      ]
    }
  },
  props: ['pageStack'],
}
</script>
Home.vue

タブバーの左側のページです。Push Pageボタンをクリックするとページスタック(pageStack)にPageNav1をプッシュします。ナビゲーションはページスタックの更新によりPageNav1を表示します。

遷移先のPageNav1でもページスタックにプッシュやポップをしたいのですが、HomeとPageNav1は親子ではなく兄弟関係になるのでpropsでデータを渡せません。代わりにextends(※)を使用してデータを渡します。

※ extendsについてはOnsen UIガイドのv-ons-navigatorの [実例] – [Passing data around]で解説されています。

<template>
  <v-ons-page>
    <h2>Home</h2>
    <div style="text-align: center">
      <br>
      <v-ons-button @click="push">
        Push Page
      </v-ons-button>
    </div>
    <br>
  </v-ons-page>
</template>

<script>
import PageNav1 from './PageNav1'

export default {
  methods: {
    push () {
      var pageToPush = {
        extends: PageNav1,
        key: 'PageNav1',
        props: ['pageStack']
      }
      this.pageStack.push(pageToPush)
    }
  },
  props: ['pageStack']
}
</script>
Cards.vue

タブバーの中央のページです。Cardリストをクリックするとページスタック(pageStack)にPageNav2をプッシュします。ナビゲーションはページスタックの更新によりPageNav2を表示します。

遷移先のPageNav2にcardTitleを渡すためextendsを使用しています。

<template>
  <v-ons-page>
    <h2>Cards</h2>
    <v-ons-list-title>Card List</v-ons-list-title>
    <v-ons-list>
      <v-ons-list-item @click="push">Card One</v-ons-list-item>
      <v-ons-list-item @click="push">Card Two</v-ons-list-item>
      <v-ons-list-item @click="push">Card Three</v-ons-list-item>
    </v-ons-list>
  </v-ons-page>
</template>

<script>
import PageNav2 from './PageNav2'

export default {
  methods: {
    push ($event) {
      var cardTitle = $event.target.textContent
      var pageToPush = {
        extends: PageNav2,
        key: 'PageNav2',
        data () {
          return { cardTitle }
        }
      }
      this.pageStack.push(pageToPush)
    }
  },
  props: ['pageStack']
}
</script>
Settings.vue

タブバーの右側のページです。

<template>
  <v-ons-page>
    <h2>Settings</h2>
  </v-ons-page>
</template>
PageNav1.vue

Homeからプッシュするページです。Push Pageボタンをクリックするとページスタック(pageStack)に自分自身であるPageNav1をプッシュします。ナビゲーションはページスタックの更新によりプッシュされたPageNav1を表示します。Pop Pageボタンをクリックするとページスタックから表示中のPageNav1をポップし、一つ前のページを表示します。

遷移先のPageNav1にtitleを渡すためextendsを使用しています。

v-ons-back-buttonコンポーネントはページスタックからポップするボタンを作成します。AndroidのマテリアルデザインのUIを選択している場合、ラベル(ここでは “Back” の文字)はネイティブのデザインに合わせて非表示になるようです。

<template>
  <v-ons-page>
    <v-ons-toolbar>
      <div class="left">
        <v-ons-back-button>Back</v-ons-back-button>
      </div>
      <div class="center">
        {{ title }}
      </div>
    </v-ons-toolbar>
    <div style="text-align: center">
      <h1>Custom Page</h1>
      <p>
        <v-ons-input
          modifier="underbar"
          placeholder="Title"
          float>
        </v-ons-input>
      </p>
      <v-ons-button @click="push">
        Push Page
      </v-ons-button>
      <v-ons-button @click="pop">
        Pop Page
      </v-ons-button>
    </div>
  </v-ons-page>
</template>

<script>
import PageNav1 from './PageNav1'

export default {
  created () {
    this.title = this.title && this.title !== '' ? this.title : 'Custom Page'
  },
  methods: {
    push () {
      var title = this.$el.querySelector('ons-input').value
      var pageToPush = {
        extends: PageNav1,
        key: 'PageNav1' + '_' + this.pageStack.length,
        data () {
          return { title }
        },
        props: ['pageStack']
      }
      this.pageStack.push(pageToPush)
    },
    pop () {
      this.pageStack.pop()
    }
  }
}
</script>
PageNav2.vue

Cardsからプッシュするページです。PagaNav1と同様にv-ons-back-buttonコンポーネントをもっています。遷移元のCardsから渡されたcardTitleを表示するシンプルなページです。

<template>
  <v-ons-page>
    <v-ons-toolbar>
      <div class="left">
        <v-ons-back-button>Back</v-ons-back-button>
      </div>
    </v-ons-toolbar>
    <v-ons-card>
      <img src="https://monaca.io/img/logos/download_image_onsenui_01.png" alt="Onsen UI" style="width: 100%">
      <div class="title">{{ cardTitle }}</div>
      <div class="content">
        <div>
          <v-ons-button>
            <v-ons-icon icon="ion-thumbsup"></v-ons-icon>
          </v-ons-button>
          <v-ons-button>
            <v-ons-icon icon="ion-share"></v-ons-icon>
          </v-ons-button>
        </div>
        <v-ons-list>
          <v-ons-list-header>Bindings</v-ons-list-header>
          <v-ons-list-item>Vue</v-ons-list-item>
          <v-ons-list-item>Angular</v-ons-list-item>
          <v-ons-list-item>React</v-ons-list-item>
        </v-ons-list>
      </div>
    </v-ons-card>
  </v-ons-page>
</template>

ページスタックを親コンポーネントでのみ操作する方法

子コンポーネントでカスタムイベントをトリガ(emit)し、親コンポーネントのイベントリスナでこれを受け取ってページスタックにプッシュやポップを行います。

main.js

Vueインスタンスを作成します。templateオプションにより#app要素(www/index.html)をAppコンポーネントで置き換えています。また、createdフックでandroid用のUIを適用しています。

前例のmain.jsと同一の内容です。

import 'onsenui';
import Vue from 'vue';
import VueOnsen from 'vue-onsenui';

// Onsen UI Styling and Icons
require('onsenui/css-components-src/src/onsen-css-components.css');
require('onsenui/css/onsenui.css');

import App from './App.vue';

Vue.use(VueOnsen);

new Vue({
  el: '#app',
  template: '<app></app>',
  components: { App },
  created () {
    this.$ons.platform.select('android')
  }
});
App.vue

Onsen UIのナビゲーションを配置してページのスタック管理を行います。子コンポーネントからトリガされたカスタムイベントを受け取り、ページスタックにプッシュやポップを行います。

ページスタックの操作はこのコンポーネントでのみで行うため、前例のApp.vueのようなページスタックを子コンポーネントに渡すための記述の代わりにイベントリスナとメソッドを記述します。

<template>
  <v-ons-navigator
    :page-stack="pageStack"
    @push-page="push"
    @pop-page="pop">
  </v-ons-navigator>
</template>

<script>
import AppTabbar from './AppTabbar'

export default {
  data () {
    return {
      pageStack: [AppTabbar]
    }
  },
  methods: {
    push (page) {
      this.pageStack.push(page)
    },
    pop () {
      this.pageStack.pop()
    }
  }
}
</script>
AppTabbar.vue

ナビゲーションの先頭にスタックされるページです。ツールバーとタブバーで構成されます。

前例のAppTabbar.vueのようなページスタックを子コンポーネントに渡すための記述の代わりにイベントリスナとメソッドを記述します。子コンポーネントのHomeやCardsからトリガされたカスタムイベントを親コンポーネントのAppへ中継します。

<template>
  <v-ons-page>
    <v-ons-toolbar>
      <div class="center">Navigator+Tabbar</div>
    </v-ons-toolbar>

    <v-ons-tabbar
      position="auto"
      :tabs="tabs"
      :index.sync="activeIndex"
      @push-page="push"
      @pop-page="pop">
    </v-ons-tabbar>
  </v-ons-page>
</template>

<script>
import Home from './Home'
import Cards from './Cards'
import Settings from './Settings'

export default {
  key: 'AppTabbarNav',
  data () {
    return {
      activeIndex: 0,
      tabs: [
        {
          icon: 'ion-home',
          label: 'Home',
          page: Home,
          key: "Home",
        },
        {
          icon: 'ion-card',
          label: 'Cards',
          page: Cards,
          key: "Cards",
        },
        {
          icon: 'ion-ios-cog',
          label: 'Settings',
          page: Settings,
          key: "Settings",
        }
      ]
    }
  },
  methods: {
    push (page) {
      this.$emit('push-page', page)
    },
    pop () {
      this.$emit('push-page')
    }
  }
}
</script>
Home.vue

タブバーの左側のページです。Push Pageボタンをクリックすると親コンポーネントのAppTabbarへカスタムイベントをトリガします。

<template>
  <v-ons-page>
    <h2>Home</h2>
    <div style="text-align: center">
      <br>
      <v-ons-button @click="push">
        Push Page
      </v-ons-button>
    </div>
    <br>
  </v-ons-page>
</template>

<script>
import PageNav1 from './PageNav1'

export default {
  methods: {
    push () {
      this.$emit('push-page', PageNav1)
    }
  }
}
</script>
Cards.vue

タブバーの中央のページです。Cardリストをクリックすると親コンポーネントのAppTabbarへカスタムイベントをトリガします。

遷移先のPageNav2にcardTitleを渡すためextendsを使用しています。

<template>
  <v-ons-page>
    <h2>Cards</h2>
    <v-ons-list-title>Card List</v-ons-list-title>
    <v-ons-list>
      <v-ons-list-item @click="push">Card One</v-ons-list-item>
      <v-ons-list-item @click="push">Card Two</v-ons-list-item>
      <v-ons-list-item @click="push">Card Three</v-ons-list-item>
    </v-ons-list>
  </v-ons-page>
</template>

<script>
import PageNav2 from './PageNav2'

export default {
  methods: {
    push ($event) {
      var cardTitle = $event.target.textContent
      var pageToPush = {
        extends: PageNav2,
        key: 'PageNav2',
        data () {
          return { cardTitle }
        }
      }
      this.$emit('push-page', pageToPush)
    }
  }
}
</script>
Settings.vue

タブバーの右側のページです。 前例のSettings.vueと同一の内容です。

<template>
  <v-ons-page>
    <h2>Settings</h2>
  </v-ons-page>
</template>
PageNav1.vue

Homeからプッシュするページです。Push PageボタンおよびPop Pageボタンをクリックすると親コンポーネントのAppTabbarへカスタムイベントをトリガします。

遷移先のPageNav1にtitleとkeyIdを渡すためextendsを使用しています。

<template>
  <v-ons-page>
    <v-ons-toolbar>
      <div class="left">
        <v-ons-back-button>Back</v-ons-back-button>
      </div>
      <div class="center">
        {{ title }}
      </div>
    </v-ons-toolbar>
    <div style="text-align: center">
      <h1>Custom Page</h1>
      <p>
        <v-ons-input
          modifier="underbar"
          placeholder="Title"
          float>
        </v-ons-input>
      </p>
      <v-ons-button @click="push">
        Push Page
      </v-ons-button>
      <v-ons-button @click="pop">
        Pop Page
      </v-ons-button>
    </div>
  </v-ons-page>
</template>

<script>
import PageNav1 from './PageNav1'

export default {
  key: 'PageNav1',
  created () {
    this.title = this.title && this.title !== '' ? this.title : 'Custom Page'
    this.keyId = this.keyId ? this.keyId : 1
  },
  methods: {
    push () {
      var keyId = this.keyId + 1
      var title = this.$el.querySelector('ons-input').value
      var pageToPush = {
        extends: PageNav1,
        key: 'PageNav1_' + keyId,
        data () {
          return { title, keyId }
        }
      }
      this.$emit('push-page', pageToPush)
    },
    pop () {
      this.$emit('pop-page')
    }
  }
}
</script>
PageNav2.vue

Cardsからプッシュするページです。前例のPageNav2.vueと同一の内容です。

<template>
  <v-ons-page>
    <v-ons-toolbar>
      <div class="left">
        <v-ons-back-button>Back</v-ons-back-button>
      </div>
    </v-ons-toolbar>
    <v-ons-card>
      <img src="https://monaca.io/img/logos/download_image_onsenui_01.png" alt="Onsen UI" style="width: 100%">
      <div class="title">{{ cardTitle }}</div>
      <div class="content">
        <div>
          <v-ons-button>
            <v-ons-icon icon="ion-thumbsup"></v-ons-icon>
          </v-ons-button>
          <v-ons-button>
            <v-ons-icon icon="ion-share"></v-ons-icon>
          </v-ons-button>
        </div>
        <v-ons-list>
          <v-ons-list-header>Bindings</v-ons-list-header>
          <v-ons-list-item>Vue</v-ons-list-item>
          <v-ons-list-item>Angular</v-ons-list-item>
          <v-ons-list-item>React</v-ons-list-item>
        </v-ons-list>
      </div>
    </v-ons-card>
  </v-ons-page>
</template>

所感

  • ページスタックを子コンポーネントで直接操作する方法は、それぞれの子コンポーネント内に処理を記述できる(子コンポーネントのファイルだけ見ればよい)ため、ソースコードの見通しがよいと感じました。半面、データの一元管理ができない(いろいろな場所でデータが書き換わる可能性がある)ため、プログラムが複雑になると機能追加や変更、デバッグなどがちょっとたいへんになるかもと思いました。
  • ページスタックを親コンポーネントでのみ操作する方法は、データの一元管理によるメリット(データ不整合の抑止、ソースコードの保守性の向上、デバッグ効率の向上など)を得ることができそうです。こちらの方法が一般的なのかもしれません。ただ、イベントのトリガ元から親コンポーネントまでの階層が離れるとその間にあるコンポーネントはイベントの中継をする必要があり、イベントの流れをしっかり把握する必要がありそうです。また、子コンポーネント独自の処理でも親コンポーネントのデータを参照するためにメソッドを親コンポーネントに記述するべきかどうかが気になりました。参照する場合はpropsで渡して子コンポーネントのメソッドで処理し、更新する場合はemitして親コンポーネントのメソッドで処理するのがいいのでしょうか?このへんは実際に経験を積んで理解できたらと思います。

エラーと解決方法

今回遭遇したエラーとその解決方法についてまとめました。Chrome DevTools のコンソールパネルに出力されたエラーメッセージをそのまま記載しています。

VSCodeでnpm run devしたときに発生した通信エラー

Failed to load resource: the server responded with a status of 404 (Not Found)

WebSocket connection to ‘ws://0.0.0.0:8081/’ failed: Error in connection establishment: net::ERR_ADDRESS_INVALID

環境はWindows10です。ブラウザのURL欄に「localhost:8080」と入力すると無事画面が表示されるのですが、2段目のエラーは複数回発生するためコンソールパネルが荒らされてしまいます。

これは、./webpack.config.jsのhostの記述を修正すると解決しました。

// 修正前
const host = process.env.MONACA_SERVER_HOST || argvs.host || '0.0.0.0';

// 修正後
const host = process.env.MONACA_SERVER_HOST || argvs.host || 'localhost';

未定義の_withTaskプロパティが読めないという謎エラー

TypeError: Cannot read property ‘_withTask’ of undefined
at ji (vendors~app.bundle.js:26)
at se (vendors~app.bundle.js:26)
at Array.Bi (vendors~app.bundle.js:26)
at C (vendors~app.bundle.js:26)
at a.t.nodeOps as patch
at a.t._update (vendors~app.bundle.js:26)
at a.n (vendors~app.bundle.js:26)
at De.get (vendors~app.bundle.js:26)
at De.run (vendors~app.bundle.js:26)
at Le (vendors~app.bundle.js:26)
Wt @ vendors~app.bundle.js:26
Vt @ vendors~app.bundle.js:26
qt @ vendors~app.bundle.js:26
(anonymous) @ vendors~app.bundle.js:26
Gt @ vendors~app.bundle.js:26

イベントリスナに登録したメソッドの定義を記述していなかったのが原因でした。ただし、このエラーを再現しようとしたのですがうまくいきませんでした。Push Pageボタンを押して動的にコンポーネントを追加するときに発生したと思っていたのですが。詳細は謎ですが、このエラーが発生したときはイベントリスナに登録したメソッドがちゃんと存在するか確認するとよさそうです。

キーがプリミティブ値じゃないというエラー

Avoid using non-primitive value as key, use string/number value instead.

Vue公式サイトにちゃんと書いてありました。

オブジェクトや配列のような非プリミティブ値を v-for のキーとして使わないでください。代わりに、文字列や数値を使ってください。

https://jp.vuejs.org/v2/guide/list.html#key

ということで、キーをオブジェクト(page)から文字列(page.key)に修正して解決しました。

<!-- 修正前 -->
<component
  v-for="page in pageStack"
  :is="page"
  :key="page"
  :page-stack="pageStack"
  @push-page="push"
  @pop-page="pop">
</component>

<!-- 修正後 -->
<component
  v-for="page in pageStack"
  :is="page"
  :key="page.key"
  :page-stack="pageStack"
  @push-page="push"
  @pop-page="pop">
</component> 

MonacaでFont Awesome 5を使ってみました

MonacaクラウドIDEで作成したOnsen UIのプロジェクトからFont Awesomeのバージョン5のアイコンを使いたくて調べてみました。MonacaクラウドIDEもFont Awesomeもひさしぶりにさわったのですが、いつの間にか進化していて戸惑いました。諸行無常を感じます。

Note

  • 本記事で利用するFont Awesomeのバージョンは5.0.13です。
  • MonacaクラウドIDEはバージョンが分からなかったのですが、2018/6時点のものになります。
  • Font Awesomeのファイルについて、記事ではminify(コード圧縮)されたファイル(.min.css)を使用していますが、minify無しのファイルに読み替えても差し支えありません。

Font Awesome 5

Font Awesomeはバージョン5から使い方が変更されたようです。私は今頃気が付きました。公式サイトをざっと見たところ使い方が4種類あるようです。

  • Web Fonts with CSS
  • SVG with JS
  • Desktop Use
  • Advanced

公式サイトの「How to Use」のページではSVG with JavaScriptにお勧めマークRECOMMENDEDがついています。SVGはベクター画像なので解像度の違いで拡大縮小が発生してもきれいに表示できるという利点があります。

このほかに変更された点は、アイコンが4種類のスタイルを持ったことです。

  • Solid
  • Regular
  • Light
  • Brands

これらのスタイルはそれぞれ対応するクラスを指定して利用します。これまではfaクラスを指定していましたが、バージョン5からはスタイルに合わせてfas、far、fal、fabクラスを指定することになります。

ちなみにRegularは一部有料版、Lightはすべて有料版のようです。無料で利用したい場合はアイコン名だけではなく、スタイルが利用可能かどうかを確認する必要があります。公式サイトの「Icons」のページでは、左側のメニューからSolidなどのスタイルや無料(Free)、有料(Pro)の絞り込みをすることができます。

MonacaクラウドIDE

Onsen UIのプロジェクトを作成すると既にFont Awesomeの設置と読み込みの記述がされているため、自分でlinkなどの記述をしなくてもFont Awesomeを利用できる状態になっています。

<link rel="stylesheet" href="lib/onsenui/css/onsenui.css">

/*! onsenui - v2.10.1 - 2018-06-08 */
@import url("ionicons/css/ionicons.min.css");
@import url("material-design-iconic-font/css/material-design-iconic-font.min.css");
@import url("font_awesome/css/font-awesome.min.css");

*  Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome

Font Awesomeのバージョンは4.7.0のようです。

Font Awesome 5の導入例

Font Awesome 5を使うために調べたことや作業の過程をご紹介します。やりかたはたくさんあると思いますので、ほんの一例として見て頂けたらと思います。

(1)デフォルト設置のFont Awesome 4.7.0の削除

基本的に外部のJavaScriptやCSSのライブラリをプロジェクトへ追加・削除したいときは、MonacaクラウドIDEの設定メニューにある「JS/CSSコンポーネントの追加と削除…」から行いますが、デフォルトで設置されているFont Awesomeはcomponentsフォルダではなくlibフォルダのほうにあり、この設定画面では認識されず削除することができません。

そこで手動で削除します。消し残しがあるとトラブルの元になるので慎重に。編集メニューのプロジェクト内検索で「font_awesome」を検索してみます。すると以下の3か所がヒットしました。

  • /www/lib/onsenui/css/onsenui-fonts.css
  • /www/lib/onsenui/css/onsenui.min.css
  • /www/lib/onsenui/css/onsenui.css

プロジェクト内でFont AwesomeにふれているのはOnsen UIのCSSファイルだけのようです。「onsenui-fonts.css」、「onsenui.min.css」を検索すると一つもヒットしないため、この2ファイルは現在使われていないようです。

onsenui.cssを開き、Font Awesomeを読み込んでいる行をコメントアウトします。今回はついでということで他のWebフォントを読み込んでいる行もまとめてコメントアウトしました。

/*! onsenui - v2.10.1 - 2018-06-08 */
/*
@import url("ionicons/css/ionicons.min.css");
@import url("material-design-iconic-font/css/material-design-iconic-font.min.css");
@import url("font_awesome/css/font-awesome.min.css");
*/

次にWebフォントの本体をフォルダごと削除します。

  • /www/lib/onsenui/css/font_awesome
  • /www/lib/onsenui/css/ionicons
  • /www/lib/onsenui/css/material-desigh-iconic-font

(2)Font Awesome 5の設置

今回はさくっと簡単に表示したいので、これまでと同じ匂いのするWeb Fonts with CSSの方法を使います。

Font Awesomeの読み込みはCDNを利用する方法とファイルをサーバにアップロードして行う方法がありますが、今回はオフラインでも安心なアップロードする方法にします。

MonacaクラウドIDEの「JS/CSSコンポーネントの追加と削除…」を使用するとライブラリの設置が簡単にできます。とても便利なのですが、その代わり余計なファイルがたくさんくっついてくるため、今回は手動で行います。

「JS/CSSコンポーネントの追加と削除…」を実行した場合

今回必要なのはweb-fonts-with-cssのなかの数ファイルのみ。不要なファイルやフォルダをIDE上でひとつひとつ削除するのがちょっとめんどいです。APKを構築するためのビルド機能に、実際に使われている関数やCSSのみ抜き出すリンカのような仕組みがあれば不要なファイルを気にしなくていいと思うのですが。

まず、Font Awesomeの公式サイトのトップページからファイルをダウンロードします。「Download Free」ボタンを押すとfontawesome-free-5.0.13.zipというファイルがダウンロードされます。

https://fontawesome.com/

ダウンロードしたZIPファイルを解凍します。ここから必要なファイルだけプロジェクトに追加します。今回はバージョン5.0.13で追加された「かえる」のアイコン(fa-frog)を表示してみます。スタイルはSolidです。

今回必要なファイルは以下の7つになります。

web-fonts-with-css/cssフォルダ内

  • fa-solid.min.css
  • fontawesome.min.css

web-fonts-with-css/webfontsフォルダ内

  • fa-solid-900.eot
  • fa-solid-900.svg
  • fa-solid-900.ttf
  • fa-solid-900.woff
  • fa-solid-900.woff2

fa-solid.cssの中身を覗いてみると、@font-faceで指定しているフォントファイル(eot, svg, ttf, woff, woff2)へのパスが一階層上の「webfonts」という名称のフォルダを指しています。

@font-face {
  font-family: 'Font Awesome 5 Free';
  font-style: normal;
  font-weight: 900;
  src: url("../webfonts/fa-solid-900.eot");
  src: url("../webfonts/fa-solid-900.eot?#iefix") format("embedded-opentype"), url("../webfonts/fa-solid-900.woff2") format("woff2"), url("../webfonts/fa-solid-900.woff") format("woff"), url("../webfonts/fa-solid-900.ttf") format("truetype"), url("../webfonts/fa-solid-900.svg#fontawesome") format("svg"); }

そこで、MonacaクラウドIDE上でlibフォルダの下にfontawesomeフォルダ(名称任意)を作成し、そのなかにcssフォルダ(名称任意)と「webfonts」フォルダを作成します。

次に、それぞれのフォルダに前述のファイルをアップロードします。

アップロード完了

Note

Onsen UIをアップデートした場合、onsenui.cssでFont Awesomeをimportしている行を再度コメントアウトする必要があります。Font Awesome本体も復活すると思います。これらのパスはcomponents/loader.cssによりwww/components側に変わると思われるので確認が必要です。

(3)Font Awesome 5の読み込み

index.htmlから、文字コードを格納しているfontawesome.min.cssと、@font-faceを記述しているfa-solid.min.cssを読み込みます。

<link rel="stylesheet" href="lib/fontawesome/css/fa-solid.min.css">
<link rel="stylesheet" href="lib/fontawesome/css/fontawesome.min.css">

i要素でfasクラスとfa-frogクラスを指定してFont Awesomeのバージョン5のアイコンを表示させてみます。今回は実験的にons-iconコンポーネントも使ってみます。

<i class="fas fa-frog" style="font-size: 100px; color: green"></i>
<ons-icon icon="fa-frog" size="100px" style="color: green"></ons-icon>

Monacaデバッガで撮ったスクショ

表示することができました!

ons-iconコンポーネント使用時の注意点

ons-iconコンポーネントを使う場合はファイルを読み込む順番に注意が必要です。ons-iconコンポーネントはons-iconクラスとfaクラスを生成します。ここで問題になるのがfont-weightプロパティの上書きです。

ons-iconクラスではFont Awesomeのスタイルを分類するfont-weightプロパティをnormalに設定します。

.ons-icon {
  display: inline-block;
  line-height: inherit;
  font-style: normal;
  font-weight: normal;
  font-size: inherit;
  text-rendering: auto;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

normalの値は「400」ですが、これはRegularスタイルと同値になります。

.fa,
.fas {
  font-family: 'Font Awesome 5 Free';
  font-weight: 900; }

.far {
  font-family: 'Font Awesome 5 Free';
  font-weight: 400; }

つまり、ons-iconコンポーネントではons-iconクラスによるRegularスタイルの指定とfaクラスによるSolidスタイルの指定が混在していることになります。

かえるの実験ではFont Awesomeのファイルを読み込んだ後にonsenui.cssを読み込んでいました。このケースでは、font-weightが「normal」となりRegularスタイルを指定したことになりますが、Regularスタイルのフォントファイルを読み込んでいないためウェイトのフォールバックでSolidスタイルのフォントファイルが使われたと考えられます。もしRegularスタイルのフォントファイルも一緒に読み込んでいた場合は、farクラスを指定していなくてもRegularスタイルのアイコンが表示されるという怪奇現象になりますし、Regularスタイルに非対応のアイコンであれば表示すらされません。

ons-iconコンポーネントを使うのであれば、onsenui.cssを読み込んだ後にFont Awesomeのファイルを読み込むことで、ons-iconクラスによるfont-weightの上書きを防げます。また、SolidスタイルとRegularスタイルのフォントファイルを一緒に読み込むときは、fa-solid.cssを読み込んだ後にfa-regular.cssを読み込むことでfaクラスによるfont-weightの上書きを防げます。

以下にons-iconコンポーネントを使用してSolidスタイルとRegularスタイルのアイコンを表示する例を示します。(Regularスタイル用のファイル一式を追加でアップロードしています)

<link rel="stylesheet" href="components/loader.css">
<link rel="stylesheet" href="lib/onsenui/css/onsenui.css">
<link rel="stylesheet" href="lib/onsenui/css/onsen-css-components.css">

<link rel="stylesheet" href="lib/fontawesome/css/fa-solid.min.css">
<link rel="stylesheet" href="lib/fontawesome/css/fa-regular.min.css">
<link rel="stylesheet" href="lib/fontawesome/css/fontawesome.min.css">

<link rel="stylesheet" href="css/style.css">

<ons-icon icon="fa-bell" size="100px" style="color: yellow"></ons-icon>
<ons-icon icon="fa-bell" size="100px" style="color: yellow" class="fas"></ons-icon>
<ons-icon icon="fa-bell" size="100px" style="color: yellow" class="far"></ons-icon>

Monacaデバッガで撮ったスクショ

表示はできましたが、本来不要であるファイルの依存関係ができてしまいました。私はこのやり方を忘れる自信があります。

まとめ

  • 現在のOnsen UIのプロジェクトではFont Awesomeのバージョン4.7.0が使われている
  • Awesomeのバージョン5を使う場合は、重複を防ぐためバージョン4.7.0を削除する(読み込みはonsenui.cssで行われている)
  • Font Awesomeのスタイル定義ファイル(fa-solid.cssなど)では、一階層上の「webfonts」フォルダへのパスが使われているので注意
  • 現時点ではons-iconコンポーネントはとりあえずi要素に変更したほうが安心
  • 開発環境やツールなどの変化は速いのでたまにさわっておいたほうがいい

ons-toolbarに配置したons-iconの位置ずれ

ons-toolbarにons-iconを配置すると、アイコンがツールバーの上下中央から少しずれてしまうことがあります。

HTML

<ons-page id="first-page">
  <ons-toolbar>
    <div class="center">Page 1</div>
    <div class="right">
      <ons-toolbar-button onclick="">
        <ons-icon icon="fa-cog" size="lg"></ons-icon>
      </ons-toolbar-button>
    </div>
  </ons-toolbar>
</ons-page>

この位置ずれについて調べました。

Note

Onsen UIFont Awesomeのお話になります。

CSSスタイルの優先順位

突然ですが、まず最初にCSSスタイルの優先順位について確認しておきます。
スタイルの優先順位 — HTMLクイックリファレンスを参考にざっくりまとめてみました。

  • よりタグに近い、より後から読み込まれたスタイルが優先される
  • セレクタの種類による優先順位
    • idセレクタ>classセレクタ>タイプセレクタ>全称セレクタ
    • 要素を特定したidセレクタ・classセレクタは、要素を特定しないidセレクタ・classセレクタより優先される
    • 優先順位が同じになった場合は最後に指定したスタイルが適用される
  • !important を付けると最優先になる

こうゆうのは文章を追うよりも実際にコードを見たり動かしたりしたほうが覚えますよね。そこで確認の意味も含め、私が考えたCSSクイズを出題したいと思います。

div要素は何色になるでしょう?

全部で2問。答えはこのページに直書きしているので、なるべくスクロールせず答えを隠して考えてもらえたらと思います。

第1問

style.css

.hoge {
  background-color: blue;
}

HTML

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>CSS TEST</title>
  <style>
  .hoge {
    background-color: yellow;
  }
  </style>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="hoge" style="width:300px; height:300px;"></div>
</body>
</html>
第2問

external.css

.piyo {
  background-color: red;
}

style.css

.hoge {
  background-color: blue;
}

HTML

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>CSS TEST</title>
  <link rel="stylesheet" href="external.css">
  <link rel="stylesheet" href="style.css">
  <style>
  div {
    background-color: yellow;
  }
  </style>
</head>
<body>
  <div class="hoge piyo" style="width:300px; height:300px;"></div>
</body>
</html>

答えを隠すためにスペースを入れますね。

もういいかな …

さて、正解は …

どちらもです。当たりました?

第1問は優先順位を「インライン>外部スタイルシート」と覚えていると間違えてしまいます。

第2問はclass属性内のクラス名の記述順序は優先順位に無関係ということです。意外と説明されていないようですので間違えやすいのではと思いました(私は間違えてました)。

位置ずれの原因と対策

本題に入ります。
ons-iconコンポーネントのsize属性はアイコンの大きさを設定します。値はlg, 2x, 3x, 4x, 5x, およびピクセルで、それぞれFont Awesomeのクラス名に対応しています。

font-awesome.css

.fa-lg {
  font-size: 1.33333333em;
  line-height: 0.75em;
  vertical-align: -15%;
}
.fa-2x {
  font-size: 2em;
}
.fa-3x {
  font-size: 3em;
}
.fa-4x {
  font-size: 4em;
}
.fa-5x {
  font-size: 5em;
}

fa-lgクラスだけ特殊のようです。font-sizeを親のフォントサイズの33倍にし、line-heightをその0.75倍しています。line-height = fontsize(親) × 1.33 × 0.75 = fontsize × (4/3) × (3/4) = fontsize、ということでline-heightを親のフォントサイズに合わせているんですかね?で、それを親のbaselineの位置から親のline-heightの15%分を下げると。うんうん、なるほど、さっぱりわかりません。実際やってみるとうまくいきますし、今年は夏でもほんとオーサムということでFont Awesomeを信じることにします。

ここで、sizeを lg, 2x, 24px にして表示を比べてみます。

HTML

<ons-page id="first-page">
  <ons-toolbar>
    <div class="center">Page 1</div>
    <div class="right">
      <ons-toolbar-button onclick="">
        <ons-icon icon="fa-cog" size="lg"></ons-icon>
      </ons-toolbar-button>
    </div>
  </ons-toolbar>
</ons-page>

size=”lg”

size=”2x”

size=”24px”

sizeに lg を指定したときだけずれます!

Debug ToolでDOM構造を確認すると、ons-iconコンポーネントは展開されてons-iconクラス、fa-cogクラス、faクラス、fa-lgクラスが付与されています。

index.html

<link rel="stylesheet" href="lib/onsenui/css/onsenui.css">

<ons-icon icon="fa-cog" size="lg" modifier="material" 
class="ons-icon fa-cog fa fa-lg"></ons-icon>

次に、上記index.htmlでリンクされているonsenui.cssを見てみます。ここでは、最初のほうでfont-awesome.min.cssのインポートを行っています。

onsenui.css(最初のほう)

@import url("font_awesome/css/font-awesome.min.css");

そして、その後に各コンポーネントのクラス定義などが記述されています。

つまり、Font Awesomeのスタイルを読み込んでからOnsen UIのスタイルを読み込むことになります。

ここで、ons-iconコンポーネントに付与されているons-iconクラスとfa-lgクラスを比べてみます。

font-awesome.css(最初に読み込む)

.fa-lg {
  font-size: 1.33333333em;
  line-height: 0.75em;
  vertical-align: -15%;
}

onsenui.css(後に読み込む)

.ons-icon {
  display: inline-block;
  line-height: inherit;
  font-style: normal;
  font-weight: normal;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

Font Awesomeのfa-lgクラスとOnsenUIのons-iconクラスを一緒に指定すると、fa-lgクラスのline-heightの値がons-iconクラスのline-heightの値で上書きされます。

これが位置ずれの原因でした。

対策として自作のstyle.cssでline-heightを再度定義したところ、無事、上下中央に配置されました。

style.css

.fa-lg {
  line-height: 0.75em;
}

前述の第2問と同じで「class=”ons-icon fa-cog fa fa-lg”」と記述されていても fa-lg クラスではなく ons-icon クラスが有効になります。私はそこのところを勘違いしていて、なぜだろうと悩みながらline-heightやmargin、vertical-alignなどをこねくり回していました。

複数のライブラリを併用しているときに不思議な現象に出会ったら、ファイルの読み込む順番やスタイルの優先順位を確認するとデバッグの効率がよくなるかもしれませんね。