DevRelという単語をご存じでしょうか。Developer Relationsの略で、開発者と自社製品/サービスとの繋がりを改善、強化するマーケティング施策です。最近よく聞かれるようになったエバンジェリスト、アドボケイトといった職種はこのDevRelに属する方達です。そんなDevRelに関するカンファレンス、「DevRelCon」が東京にて7月15日に開催されます。ご興味のある方はぜひご参加ください。

DevRelCon Tokyo 2018 - DevRel、開発者向けマーケティング、開発者向けのカンファレンス

現在、DevRelConを楽しむためにMonacaを使ってDevRelCon Appを作っています(残念ながら今年は間に合わなそうですが…)。そこで、今回は開発中に得られたOnsen UI for Vueに関する知見を紹介します。

作るもの

アプリの画面構成としてありがちな、タブバー(v-ons-tabbar)の中にナビゲーション(v-ons-navigator)が入っているUIの作り方を紹介します。

ナビゲーション機能で辿れる各ページは多階層になっています。

ネイティブライクな動作のために

Onsen UIで様々なコンポーネントを組み合わせる場合、特に注意が必要なのがツールバーです。ツールバーが各タブ内にあると、タブを切り替える度にツールバーが再描画されてしまいます。これはWebアプリっぽさを感じさせるのでできればやらないことをおすすめします。
このアプリではツールバーはアプリ全体で共通として、ナビゲーションは各タブ内で独立して動くものとしました。

Onsen UI for Vueならではの注意

v-ons-navigator (ナビゲーション機能)が制御する各ページは、親子関係ではなく兄弟関係です。よって、 props を使って変数を送る仕組みはありません。代わりに extends を使います。

import nextPage from 'nextPage.vue';

// 省略

pageStack.push({
  extends: nextPage,
  data() {
    return {
      myCustomDataHere: 42
    };
  }
})

タブ毎に呼び出すナビゲーション機能は共通化しよう

今回は3つのタブがありますが、それぞれにナビゲーションを行うコンポーネントを作るのは面倒です。そこで、ナビゲーション用コンポーネントは Nav.vue にまとめます。どのページを表示するかはタブバー側で指定することにします。遷移先ページをプッシュする際に、extends を使ってデータを引き継げるようにします。

Nav.vue

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

<script>
  import HomeDetail from 'HomeDetail';
  import NewsDetail from 'NewsDetail';

  export default {
    data() {
      return {
        pageStack: [this.list]
      };
    },
    props: ['list'],
    methods: {
      popPage() {
        this.pageStack.pop();
        this.$emit('backButton', this.pageStack);  // ※2
      },
      pushPage(e) {
        if (e.page === 'HomeDetail') e.page = HomeDetail;  // ※1
        if (e.page === 'NewsDetail') e.page = NewsDetail;  // ※1
        this.pageStack.push({
          extends: e.page,
          data: () =>  e.data || {}
        });
        this.$emit('backButton', this.pageStack);
      }
    }
  }
</script>

そして、このNavコンポーネントをAppコンポーネント内で使います。タブを切り替えた時にNavコンポーネントを表示し、props でデータを渡します。このとき、v-ons-navigator が表示するコンテンツとしてHomeコンポーネントを渡します。extends にHomeコンポーネントを指定することで、AppコンポーネントからHomeコンポーネントに対してデータを渡せるようになります(今回は空ですが)。

App.vue

import Home from 'Home';
import Nav from 'Nav';

export default {
  data() {
    return {
      activeIndex: 0,
      last: null,
      ios: this.$ons.platform.isIOS(),
      tabs: [
        {
          icon: this.md() ? null : 'ion-home',
          label: 'Home',
          page: Nav,
          key: 'Home',
          props: {
            list: {
              extends: Home,
              data() {
                return {};
              }
            }
          }
        },
        // 省略
      ]
    };
  },
  // 省略
}

画面遷移について

メイン画面(タブ内の1ページ目)から詳細画面(2ページ目)に遷移する際には、NavコンポーネントのpushPage()を呼び出します。

Home.vue

<template>
  <v-ons-page>
    <p style="text-align: center">
      Welcome home!<br />
      <v-ons-button @click="pushPage" style="margin: 6px 0">詳細画面へ</v-ons-button>
    </p>
  </v-ons-page>
</template>
<script>
import HomeDetail from './HomeDetail';
export default {
  key: 'HomeMaster',
  methods: {
    pushPage(e) {
      this.$emit('push-page', {page: HomeDetail});
    }
  }
}
</script>

さらに、詳細画面(たとえば、HOMEタブの詳細画面)から別タブの詳細画面(たとえば、NEWSタブの詳細画面)に移動することもできます。

HomeDetail.vue

<template>
  <v-ons-page>
    <h2>ホーム詳細</h2>
    <p style="text-align: center">
      <v-ons-button @click="popPage" style="margin: 6px 0">戻る</v-ons-button>
      <v-ons-button @click="toNewsDetail" style="margin: 6px 0">ニュース詳細へ</v-ons-button>
    </p>
  </v-ons-page>
</template>
<script>
  export default {
    key: 'HomeDetail',
    methods: {
      popPage() {
        this.$emit('pop-page');
      },
      toNewsDetail() {
        this.$emit('push-page', {page: 'NewsDetail'});
      }
    }
  }
</script>

18行目で、NavコンポーネントのpushPage()に渡す引数として文字列型の 'NewsDetail' を指定しています。
これを受け取ったNavコンポーネントは、文字列型からオブジェクト型のNewsDetailコンポーネントに変更します。(Nav.vueの※1参照)
このような記述方法を取っているのには理由があります。
HomeDetailコンポーネント(HOMEタブの詳細画面)とNewsDetailコンポーネント(NEWSタブの詳細画面)間で相互に画面遷移させたいので、セオリー通りに記述すると以下のようになります。するとお互いをインポートし合う構成になり、無限にインポートが発生してしまいます。

HomeDetail.vue

import NewsDetail from './NewsDetail';

NewsDetail.vue

import HomeDetail from './HomeDetail';

これを防ぐために、文字列型でコンポーネント名を渡して、Navコンポーネント内でオブジェクト型に変更するようにしています。

ツールバーの戻るボタン

今回のアプリでは、Onsen UIで提供されている v-ons-back-button は使えません。v-ons-back-button を使うためには v-ons-navigator が必要ですが、v-ons-navigator はAppコンポーネントよりも下の階層にあるため、Appコンポーネント内で利用できません。戻るボタンの描画は自分で実装する必要があります。

画面遷移したタイミングで戻るボタンを表示したいのですが、ページスタック(pageStack)はNavコンポーネント内、ツールバーはAppコンポーネント内にあります。Appコンポーネントの変数を共有する方法も考えられますが、タブバーの props を使っても望んだ動作になりませんでした。

そこでどうしたかというと、Appコンポーネント内にて表示判定を行うようにしました。
まず、Nav.vueの※2に記述していた this.$emit('backButton', this.pageStack); が実行されると、App.vueのbackButton()が呼び出されます。現在のページスタック数から2引いたインデックスのページ、つまり現在表示されているページの一つ前のページをthis.lastに設定しています。
そしてstyleToolbar()の中では、this.lastが空かどうか(=現在のページよりも前にページが存在するかどうか)を判定し、CSSのスタイルを返却しています。

App.vue

backButton(pageStack) {
  this.last = pageStack[pageStack.length - 2];
},
styleToolbar() {
  return `display: ${this.last ? 'inline' : 'none'}`;
},

現在のページよりも前にページが存在する場合は、ツールバーに戻るボタンを表示します。

<v-ons-toolbar>
  <div class="header-left left" v-show="last" :style="styleToolbar()" @click="popPage">
    <v-ons-button modifier="quiet" v-if="ios">
      <v-ons-icon size="25px" icon="ion-chevron-left"></v-ons-icon> Back
    </v-ons-button>
    <v-ons-button modifier="quiet" v-else>
      <v-ons-icon size="25px" icon="md-arrow-left"></v-ons-icon>
    </v-ons-button>
  </div>
  <div class="center header-title">{{ title }}</div>
  <div class="right toolbar__right" :style="styleToolbar()"></div>
</v-ons-toolbar>

戻るボタンをタップした時の処理

戻るボタンを押した時の処理はページスタックを減らすだけなのですが、どうやってAppコンポーネントからNavコンポーネントのページスタックにアクセスすれば良いでしょうか。ここはちょっとトリッキーなのですが、refを使います。このrefを指定することで別のコンポーネントの変数にアクセスできるようになります。

App.vue

<v-ons-tabbar position="auto"
  :tabs="tabs"
  :visible="true"
  ref="Nav" <!-- ここがref指定 -->
  :index.sync="activeIndex"
  @postchange="changeTab"
  @backButton="backButton"
>
</v-ons-tabbar>

こうすることで戻るボタンを押した時にNavコンポーネントにアクセスできるようになります。ページスタック配列を減らした後に、戻るボタンの表示判定を行っています。

popPage(e) {
  this.$refs.Nav.$children[this.activeIndex].$data.pageStack.pop();
  this.backButton(this.$refs.Nav.$children[this.activeIndex].$data.pageStack);
}

タブを切り替えた時の処理

最後にタブを切り替えた時の処理を追加します。このタイミングでも戻るボタン表示判定が必要ですので、v-ons-tabbarpostchange イベントを使います。

App.vue

<v-ons-tabbar position="auto"
  :tabs="tabs"
  :visible="true"
  ref="Nav"
  :index.sync="activeIndex"
  @postchange="changeTab" <!-- ここがタブの切り替わった際の処理 -->
  @backButton="backButton"
>
</v-ons-tabbar>

changeTab()は、戻るボタンの表示判定を行うだけのメソッドです。

changeTab() {
  this.backButton(this.$refs.Nav.$children[this.activeIndex].$data.pageStack);
},

まとめ

以上で、v-ons-tabbar の中に v-ons-navigator を配置するサンプルアプリの完成です。多階層を辿ったり、別タブの画面に遷移したりすることもできるので、画面数が多いアプリを作る際の参考になるのではないでしょうか。

今回のコードはgoofmint/OnsenUI-Tab-Navにアップロードしてあります。実装時の参考にしてください。