[Onsen UI + Vue] v-ons-card トランジション

Onsen UIのカードコンポーネントv-ons-cardにVueのトランジションを適用したサンプルを作成しました。

See the Pen Onsen UI Vue by senmyou (@senmyou) on CodePen.

リスト移動トランジションについて

本サンプルのトランジションはVue公式マニュアルのリスト移動トランジションを参考にしました。要素を削除すると、対象要素の透過トランジションと周辺要素の移動トランジションが同時に発生します。

じつは私はこのマニュアルの解説をなんど読んでも理解できませんでした。この直前で説明していたv-moveクラスはなぜか使われていないですし、いきなりv-enter-active/v-leave-activeクラスからtransitionプロパティが消え、かわりに謎のposition: absoluteが追加されていたりします。

なにか違うコードを載せちゃったのかなと思ったりもしましたが、なぜかうまく動きます。解せません。そこでDOM 変更ブレークポイントを使って調べてみました。

DOM変更ブレークポイント

attributes modificationsを選択すると属性の変更を追跡できます。

FLIP

transition-groupコンポーネントのトランジション処理にはFLIPが使われています。

内部で Vue は transforms を使って、前の位置から新しい位置へ要素を滑らかにトランジションさせるために FLIP と呼ばれる単純なアニメーションテクニックを使っています。

https://jp.vuejs.org/v2/guide/transitions.html#リスト移動トランジション

FLIPはFirst、Last、Invert、Playの頭文字で、アニメーションの実行前にJavaScriptで事前計算(First、Last、Invert)を行い、CSSにアニメーション処理をさせる(Play)技術です。

  • First
    • 要素の初期状態を取得
  • Last
    • 要素をアニメーション実行後の状態に設定
    • 要素の状態を取得
  • Invert
    • FirstとLastの変化量(差分)を算出
    • 要素に変化量を適用(Firstの状態に戻る)
  • Play
    • 要素にアニメーション用のクラスを追加
    • Invertで適用した変化量を削除(FirstからLastへのアニメーションが起動)

事前計算のコストは発生しますが、その分アニメーションの実行中にかかるコストがなくなるため、スムーズなアニメーションを実現できます。

デバッグ

本サンプルの3番目(真ん中)のカードを削除するケースを調べます。3番目と5番目のカードにDOM変更ブレークポイントを付け、3番目のカードの削除ボタンを押します。

3番目のカードが消えると4番目と5番目のカードがひとつ上に移動します。このとき3番目のカードにはv-leave、v-leave-to、v-leave-activeクラス、4番目と5番目のカードにはv-moveクラスが追加・削除されました。以下にブレークポイントにより停止したポイントの内容を示します。

丸数字:カード番号

  1. ③ にv-leaveを追加
  2. ③ にv-leave-activeを追加(追加した瞬間 ④ と ⑤ がひとつ上に移動。③ は ④ に隠れる)
  3. ⑤ のtransformプロパティに”translate(0px, 115.2px)”、transitionDurationプロパティに”0s”を代入
  4. ⑤ にv-moveを追加
  5. ⑤ のtransformプロパティに空文字を代入(代入した瞬間 ⑤ がひとつ上に移動)
  6. ⑤ からv-moveを削除
  7. ③ にv-leave-toを追加
  8. ③ からv-leaveを削除
  9. ③ からv-leave-toを削除
  10. ③ からv-leave-activeを削除

実際は3番目のカードと4番目&5番目のカードで別々のトランジションが同時に発生しているので、ブレークで止めることでお互いのタイミングがずれてみえることがあるかもしれませんが、それぞれのトランジションの流れは把握できそうです。

この内容をFLIPにあてはめると、No.2はアニメーションの最終的な状態になっているのでLast、No.3は5番目のカードを下に移動して元の位置に戻しているのでInvert、No.4とNo.5はアニメーション用のクラスを追加しtransformプロパティの値を空文字で消してアニメーションを発生させているのでPlayの処理に関連していると言えそうです。

次に、v-leave-activeクラスのposition: absoluteをマスクして、同じように3番目のカードを削除してみます。

  1. ③ にv-leaveを追加
  2. ③ にv-leave-activeを追加
  3. ③ にv-leave-toを追加
  4. ③ からv-leaveを削除
  5. ③ からv-leave-toを削除
  6. ③ からv-leave-activeを削除

4番目と5番目のカードはアニメーションせずに移動しました。FLIPが適用されなかったようです。v-moveクラスの追加も行われていません。

考察

以下はデバッグの結果から私が個人的に考察した内容になります。間違っている可能性がありますのでご注意ください。

要素を追加する場合はアニメーション開始前に実際に要素が追加されます。その後opacityを0から1に変化させていきます。それに対し、要素を削除する場合はアニメーション開始前に要素を削除しません。opacityを1から0に変化させていき最後に削除します。削除のケースでは、FLIPの事前計算のときに要素が実際には削除されていないため、このままでは変化量が0となりFLIPが適用されません。そこで削除する要素のpositionプロパティをabsoluteに設定してレイアウトから外すことで、実際のアニメーション実行後の状態を取得し、算出した変化量をFLIPでアニメーションさせることができるようになります。

ちなみに、v-leave-activeクラスのかわりにv-leaveクラスにposition: absoluteを記述して動作確認してみました。結果、FLIPが適用され、変化量(移動量)も正しいように見えました。ですが、v-leaveクラスはトランジション開始前に削除される仕様なので、そのタイミングでレイアウトから外していた要素が元に戻り、カードひとつ分下の位置からアニメーションが開始されているようにみえました。その点、v-leave-activeクラスはトランジション終了時に削除される仕様なので問題ないといえます。

リスト移動トランジションについての当初の疑問に回答を付けてみます。

  • 追加・削除対象要素に追加されるv-enter-active/v-leave-activeクラスと、周辺要素に追加されるv-moveクラスのtransitionプロパティの値が同じということで、共通で使用するクラスにtransitionプロパティを記述した。よって、v-enter-active/v-leave-activeクラスとv-moveクラスにtransitionプロパティを記述する必要がなくなった。(それぞれのクラスにtransitionプロパティを記述しても問題なし)
  • v-leave-activeクラスでposition: absoluteとしているのは周辺要素にFLIPを適用させるため。 FLIPの事前計算を行うときに削除する要素をabsoluteによりレイアウトから外すことでアニメーション実行後の状態を取得できるようになる。

サンプルのソースコード

本サンプルのソースコードについて2点説明します。

1.createdフックでkey属性に設定する文字列を作成しています。要素の追加や削除を行う場合、key属性に配列のインデックスを設定するとトランジションが正しくかからなくなります。配列の追加や削除を行うとインデックスが振り直され、要素とkey値の組み合わせが変更されるためです。配列内の値や文字列が使えればいいのですが、本サンプルでは一意の値がない(同じ値が使われる可能性がある)場合に対応するためcounterを使用して一意のkey値を作成しています。

created () {
  this.$ons.platform.select('android')
  this.items.forEach(item => {
    item.key = "res_" + this.counter++
  })
}

2.FLIPを適用させるためpositionプロパティをabsoluteに設定しました。このため対象となる要素やコンポーネントによっては副作用が発生することが考えられます。カードコンポーネントはmargin: 8pxに設定されているのですが、absoluteを設定すると8px下に移動します。おそらく相殺されていた上下のmarginが相殺されなくなったためと考え、最初から相殺が起きないようにmargin-topを0に設定しました。

.mycard {
  position: relative;
  transition: all 500ms ease-in;
  margin-top: 0;
}

また、absoluteにするとカードコンポーネントの幅がコンテンツの幅に縮小されるため、leftとrightプロパティを0にして対策しました。

.cards-leave-active {
  position: absolute;
  left: 0;
  right: 0;
}

[Onsen UI + Vue] ボタンコンポーネント

Onsen UIのボタンコンポーネントv-ons-buttonをラップしたカスタムボタンコンポーネントを作ってみました。

See the Pen Onsen UI Vue by senmyou (@senmyou) on CodePen.

ボタンのスタイリングはOnsen UIにおまかせしています。Onsen UIはモバイル端末に合わせて自動的にAndroid/iOSのスタイルを適用します。上記CodePenのサンプルではパソコンでもAndroid用のスタイルに見えるようにスタイルを固定しています。そのため、マテリアルデザインとリップル(さざ波)エフェクト付きのボタンになっています。

汎用的なボタンコンポーネントを目指して作り始めたのですが、最終的にアイコンとテキストを組み合わせたトグルボタン用コンポーネントになってしまったという気もします。以下にソースを記載します。

  • CodePenではHTMLファイル/JSファイル/CSSファイルに記述していますが、以下のソースはVueファイル(単一ファイルコンポーネント)になります。
  • アイコンはFont Awesome専用になっています。ちなみにFont Awesomeの読み込みはOnsen UIのCSSファイルで行われます。
CustomButton.vue
<template>
  <v-ons-button
    :class="buttonClass"
    :disabled="disabled"
    @click="clickHandler"
  >
    <i
      v-if="icon"
      class="fa"
      :class="iconClass"
    >
    </i>
    <slot>{{ buttonText }}</slot>
  </v-ons-button>
</template>

<script>
export default {
  data () {
    return {
      index: 0,
    }
  },

  props: {
    text: {
      type: [String, Array],
      default: ''
    },
    icon: {
      type: [String, Array],
      default: ''
    },
    disabled: {
      type: Boolean,
      default: false
    },
    buttonClass: {
      type: String,
      default: ''
    }
  },

  computed: {
    toggle () {
      console.log('toggle')
      var num = 0
      var icon = (this.icon instanceof Array) ? this.icon.length : 0
      var text = (this.text instanceof Array) ? this.text.length : 0
      if (icon) {
        if (text) {
          num = icon < text ? icon : text
        } else {
          num = icon
        }
      } else if (text) {
        num = text
      }
      console.log(icon, text, num)
      return { icon, text, num }
    },
    iconClass () {    // memo: iconが空の配列の場合、暗黙的にjoin()により""になる
      console.log('iconClass')
      return this.toggle.icon ? 'fa-' + this.icon[this.index] : 'fa-' + this.icon
    },
    buttonText () {
      console.log('buttonText')
      return this.toggle.text ? this.text[this.index] : this.text
    }
  },

  methods: {
    clickHandler (event) {
      var index = this.index;
      if (this.toggle.num) {
        this.index = (index >= this.toggle.num - 1) ? 0 : index + 1
      }
      var arg = {
        event,
        oldIdx: index,
        newIdx: this.index
      }
      this.$emit('click', arg)
    }
  }
}
</script>

<style scoped>
/*
・親コンポーネントのクラスで上書きされる
・scopedを付けないとOnsen UIのbuttonクラスで上書きされる
  (onsen-css-components.css .button)
*/
.greenButton,
.greenButton:active {
  background-color: green;
}
.redButton,
.redButton:active {
  background-color: red;
}
</style>

このコンポーネントの使い方について説明します。

ボタンのテキストはコンポーネントの開始タグと終了タグの間に記述する方法と、textプロパティに記述する方法の2通りに対応しています。アイコンはiconプロパティに記述します。テキストとアイコンは一緒に使用することができます。それぞれの位置関係は固定です。コンポーネントにプロパティを追加して上下に配置できるようにするのもいいかもしれません。

<custom-button>slot</custom-button>
<custom-button text="props"></custom-button>

<custom-button icon="cog"></custom-button>

<custom-button icon="cog">slot</custom-button>
<custom-button icon="cog" text="text"></custom-button>

disabled属性でボタンの有効/無効を設定できます。

<custom-button disabled>hello</custom-button>
<custom-button :disabled="false">hello</custom-button>
<custom-button :disabled="true">hello</custom-button>

ボタンのクリックにより “click” というイベント名でイベントを発火します。また、button-classプロパティでボタンへ適用するクラスを渡すことができます。

<custom-button
  icon="smile-o"
  @click="clickHandler"
  button-class="bigButton"
>
</custom-button>

textプロパティおよびiconプロパティに配列を渡すことでトグルボタンやグルグルボタンになります。

<custom-button
  :text="['グー', 'チョキ', 'パー']"
  @click="clickHandler"
  button-class="sample01"
>
</custom-button>

<custom-button
  :icon="['hand-rock-o', 'hand-peace-o', 'hand-paper-o']"
  @click="clickHandler"
  button-class="sample02"
>
</custom-button>

<custom-button
  :text="['グー', 'チョキ', 'パー']"
  icon="smile-o"
  @click="clickHandler"
  button-class="sample03 align-left"
>
</custom-button>

<custom-button
  text="じゃんけん"
  :icon="['hand-rock-o', 'hand-peace-o', 'hand-paper-o']"
  @click="clickHandler"
  button-class="sample04 align-right"
>
</custom-button>

<custom-button
  :text="['グー', 'チョキ', 'パー']"
  :icon="['hand-rock-o', 'hand-peace-o', 'hand-paper-o']"
  @click="clickHandler"
  button-class="sample03 align-left"
>
</custom-button>

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