[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>