18
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

OPENLOGIAdvent Calendar 2018

Day 8

@ionic/vueでハイブリッドアプリを作ってみた

Last updated at Posted at 2018-12-08

作ったアプリ

memo_gif.gif

こんなアプリを@ionic/vueで作ってみました。
アプリの機能としては

  • アプリっぽい動きを実装する
    • スマホっぽい無限スクロールで、スクロールの下限に達したらAPIでfetchして、スクロールを伸ばす
  • メモの作成
  • メモの閲覧

を実装しました。

書くきっかけ

A Vue from Ionicという記事を見つけて、自分で試してみようと思ったのがきっかけです。
調べてみたらこんな記事を発見。beepというオープンソースのサンプルアプリがあったので、これを参考にして開発を進めることにしました。

利用したライブラリ

package.json
{
  "name": "vue-ionic-sample",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint",
    "build-android": "npx cap sync android && npx cap copy android",
    "build-ios": "npx cap sync && npx cap copy ios"
  },
  "dependencies": {
    "@capacitor/cli": "^1.0.0-beta.11",
    "@capacitor/core": "^1.0.0-beta.11",
    "@capacitor/ios": "^1.0.0-beta.11",
    "@ionic/core": "^4.0.0-beta.17",
    "@modus/ionic-vue": "^1.1.1",
    "axios": "^0.18.0",
    "vue": "^2.5.17",
    "vue-class-component": "^6.0.0",
    "vue-property-decorator": "^7.0.0",
    "vue-router": "^3.0.1",
    "vue-router-layout": "^0.1.2",
    "vuex": "^3.0.1",
    "vuex-class": "^0.3.1",
    "vuex-type-helper": "^1.2.0"
  },
  "devDependencies": {
    "@vue/cli-plugin-babel": "^3.2.0",
    "@vue/cli-plugin-typescript": "^3.2.0",
    "@vue/cli-service": "^3.2.0",
    "typescript": "3.0.3",
    "vue-auto-routing": "^0.3.0",
    "vue-cli-plugin-auto-routing": "^0.2.1",
    "vue-template-compiler": "^2.5.17"
  }
}

執筆時点(2018/12/8)では上記のライブラリを利用しています。
vue-cliの開始時点で

  • Babel
  • TypeScript
  • Vue-Router
  • Vuex
  • Tslint
  • vue-class-component

等々はあらかじめ入った状態で、開発をはじめたので独自で導入したプラグインは下記になります

開発途中で導入したプラグイン
   "dependencies": {
+    "@capacitor/cli": "^1.0.0-beta.11",
+    "@capacitor/core": "^1.0.0-beta.11",
+    "@capacitor/ios": "^1.0.0-beta.11",
+    "@ionic/core": "^4.0.0-beta.17",
+    "@modus/ionic-vue": "^1.1.1",
+    "axios": "^0.18.0",
     "vue": "^2.5.17",
     "vue-class-component": "^6.0.0",
     "vue-property-decorator": "^7.0.0",
     "vue-router": "^3.0.1",
-    "vuex": "^3.0.1"
+    "vue-router-layout": "^0.1.2",
+    "vuex": "^3.0.1",
+    "vuex-class": "^0.3.1",
+    "vuex-type-helper": "^1.2.0"
   },
   "devDependencies": {
     "@vue/cli-plugin-babel": "^3.2.0",
     "@vue/cli-plugin-typescript": "^3.2.0",
     "@vue/cli-service": "^3.2.0",
-    "typescript": "^3.0.0",
+    "typescript": "3.0.3",
+    "vue-auto-routing": "^0.3.0",
+    "vue-cli-plugin-auto-routing": "^0.2.1",
     "vue-template-compiler": "^2.5.17"
   }
 }

typescriptのversionが下がってるのは、ライブラリ導入過程で問題が発生したので、そのワークアラウンドです。
開発途中にて導入したそれぞれのライブラリについて簡単に説明したいと思います。

@ionic/vue

  • vueのtemplateにて、ionicの独自のタグを実装できるようになる
  • Vue-Routerをラップした「IonicVueRouter」を利用する必要がある

Capacitor

  • ハイブリッドアプリのビルドやネイティブのSDKの利用を簡単にできるようになる
  • 今回作成したアプリでは、vueのビルド結果をxcodeのプロジェクトとしてビルドするために利用している

vue-cli-plugin-auto-routing

  • vue-cliのroutingがNuxt.jsと同様になる
  • ただ、今回はIonicVueRouterに関連した問題が発生したため、完全にNuxt.jsのroutingに同じになっていない

実装の解説

main.ts/index.html

main.ts
import Vue from 'vue';
import { Ionic, IonicAPI } from '@modus/ionic-vue';

import router from './router';
import store from './stores/store';

Vue.config.productionTip = false;

Ionic.init();
Vue.use(IonicAPI);

new Vue({
  router,
  store,
}).$mount('#app');
index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <title>vue-ionic-sample</title>
  </head>
  <body>
    <noscript>
      <strong>We're sorry but vue-ionic-sample doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
    </noscript>
    <ion-app id="app">
      <ion-vue-router/>
    </ion-app>
    <!-- built files will be auto injected -->
  </body>
</html>

GettingStartedに載っているように、vueの起動処理を変更しました。
また、index.htmlにionincの独自タグを書くにあたり vue.config.js

vue.config.js
runtimeCompiler: true

を追加しました。

router.ts

IonicVueRouterへの対応

router.ts
import Vue from 'vue';
import { IonicVueRouter } from '@modus/ionic-vue';
import routes from 'vue-auto-routing';

Vue.use(IonicVueRouter);

export default new IonicVueRouter({
  // @see node_modules/@modus/ionic-vue/src/router.ts
  direction: 1,
  viewCount: 0,
  mode: 'history',
  routes: routes.map((route) => {
    return { ...route, path: '/' + route.path };
  }),
});

GettingStartedに載っているように、routingを変更しました。
ただ、typescriptを利用していると、下記の directiondirectionOverride の型定義が原因でコンパイルができないという問題が発生しました。

node_modules/@modus/ionic-vue/types/router.d.ts
export default class Router extends _VueRouter {
    direction: number;
    directionOverride: number | null;
    viewCount: number;
    prevRouteStack: Route[];
    history: any;
    static installed: boolean;
    static install: PluginFunction<never>;
    constructor(args?: RouterArgs);
    extendHistory(): void;
    canGoBack(): boolean;
    guessDirection(nextRoute: Route): number;
}

そこで、IonicVueRouterのコンストラクタを見たところ、下記のように引数に値がない場合の初期値が定義されていたので、その値をIonicVueRouterの初期化処理にて代入しています。

node_modules/@modus/ionic-vue/src/router.ts
export default class Router extends _VueRouter {
  direction: number;
  directionOverride: number | null;
  viewCount: number;
  prevRouteStack: Route[];
  history: any;
  static installed: boolean;
  static install: PluginFunction<never>;

  constructor(args: RouterArgs = {} as RouterArgs) {
    super(args);

    // The direction user navigates in
    this.direction = args.direction || 1;

    // Override normal direction
    this.directionOverride = null;

    // Number of views navigated
    this.viewCount = args.viewCount || 0;

vue-cli-plugin-auto-routingへの対応

vue-cli-plugin-auto-routingを導入した時点では、router.tsが下記のように更新されます。

router.ts
import Vue from 'vue';
import Router from 'vue-router';
import routes from 'vue-auto-routing';
import { createRouterLayout } from 'vue-router-layout';

Vue.use(Router);

const RouterLayout = createRouterLayout(layout => {
  return import('@/layouts/' + layout + '.vue');
});

export default new Router({
  routes: [
    {
      path: '/',
      component: RouterLayout,
      children: routes
    }
  ]
})
const RouterLayout = createRouterLayout(layout => {
  return import('@/layouts/' + layout + '.vue');
});

export default new Router({
  routes: [
    {
      path: '/',
      component: RouterLayout,
      children: routes
    }
  ]
})

/ を対象とした親のroutingが一つあり、その親がlayoutをcomponentとして持ち、さらにchildrenとしてpages以下を自動生成したroutingが持つという感じです。
ただ、これだと下記の@ionic/vue側の実装と相性が悪いです。

node_modules/@modus/ionic-vue/src/components/ion-vue-router.vue
<template>
  <ion-router-outlet
    ref="ionRouterOutlet"
    @click="catchIonicGoBack">
    <router-view
      v-if="customTransition"
      :name="name"/>
    <transition
      v-else
      :css="bindCSS"
      mode="in-out"
      @before-enter="beforeEnter"
      @enter="enter"
      @after-enter="afterEnter"
      @enter-cancelled="enterCancelled"
      @before-leave="beforeLeave"
      @leave="leave"
      @after-leave="afterLeave"
      @leave-cancelled="leaveCancelled"
    >
      <router-view :name="name"/>
    </transition>
  </ion-router-outlet>
</template>

なぜなら、親のroutingが / を対象とした一つであるため、URLが変更された場合に

<router-view
  v-if="customTransition"
  :name="name"/>

の変更が検知されず、transitionの描画が実行されないためです。

そのため対応としては、/ を対象とした親のroutingを挟まずに直接にchildrenを router.ts に配置するようにしました。

router.ts
  routes: routes.map((route) => {
    return { ...route, path: '/' + route.path };
  }),

こうすることで、 router-view が変更され、transitionの描画が実行されます。

pages/about.vue

pages/about.vue
<template>
  <ion-page class="ion-page">
    <ion-header class="header">
      <ion-toolbar>
        <ion-buttons slot="start">
          <ion-back-button default-href="/" />
        </ion-buttons>
        <ion-title>このアプリについて</ion-title>
      </ion-toolbar>
    </ion-header>
    <ion-content class="content">
      <h2>
        利用してる技術
      </h2>
      <ion-list>
        <ion-item>vue-cli</ion-item>
        <ion-item>ionic</ion-item>
        <ion-item>capacitor</ion-item>
      </ion-list>
      <h2>スマホっぽいスクロール</h2>
      <ion-list>
        <ion-item v-for="(item, index) in itemsForList" :key="index">
          {{item}}
        </ion-item>
      </ion-list>

      <ion-infinite-scroll @ionInfinite="doInfinite">
        <ion-infinite-scroll-content></ion-infinite-scroll-content>
      </ion-infinite-scroll>
    </ion-content>
  </ion-page>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import axios from 'axios';

@Component
export default class About extends Vue {
  public items: string[] = [];

  get itemsForList() {
    return this.items;
  }

  async created() {
    const posts = await axios.get('https://jsonplaceholder.typicode.com/posts');
    this.items = posts.data.map((post: any) => {
      return post.title;
    });
  }

  async doInfinite(ionInfiniteEvent: any) {
    const posts = await axios.get('https://jsonplaceholder.typicode.com/posts');
    const body = posts.data.map((comment: any) => {
      return comment.body;
    });

    setTimeout(() => {
      this.items.push(...body);
      ionInfiniteEvent.target.complete();
    }, 500);
  }
}
</script>

/about のrouting先です。
でスクロールが下限に達したら ionInfinite というeventがdispatchされるので、それをキャッチして doInfinite() を実行します。
doInfinite() では、 jsonplaceholderというAPIを叩いて、それを this.items に追加しています。
また、Loadingっぽさを演出するため500ミリ秒待たせています。

pages/create/index.vue

pages/create/index.vue
<template>
  <ion-page class="ion-page">
    <ion-header class="header">
      <ion-toolbar>
        <ion-buttons slot="start">
          <ion-back-button default-href="/" />
        </ion-buttons>
        <ion-title>メモを作る</ion-title>
        <ion-buttons slot="end">
          <ion-button :disabled="!isValidMemo" clear @click="createMemo">
            <span v-if="requestPending">
              <ion-spinner/>
            </span>
            <span v-else>Done</span>
          </ion-button>
        </ion-buttons>
      </ion-toolbar>
    </ion-header>
    <ion-content class="content">
      <ion-textarea
        class="textarea"
        type="text"
        :value="memo"
        large
        @input="memo = $event.target.value"
        placeholder="メモを入力してね"
      ></ion-textarea>
    </ion-content>
  </ion-page>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { Action, Getter } from 'vuex-class';

@Component
export default class About extends Vue {
  public memo: string = '';
  public requestPending: boolean = false;

  @Action('memo/add') addMemo: any;
  @Getter('memo/memos') memos!: string[];

  get isValidMemo() {
    return this.memo !== '';
  }

  createMemo() {
    this.requestPending = true;
    setTimeout(() => {
      this.addMemo(this.memo);
      this.requestPending = false;
      this.$router.push('/');
    }, 500);
  }
}
</script>

/create のrouting先です。
右上のDONEボタンをクリックしたら createMemo() が実行されます。
createMemo() では this.memo をVuexのstoreに保存しています。
また、Savingっぽさを演出するため500ミリ秒待たせています。

pages/read/index.vue

pages/read/index.vue
<template>
  <ion-page class="ion-page">
    <ion-header class="header">
      <ion-toolbar>
        <ion-buttons slot="start">
          <ion-back-button default-href="/" />
        </ion-buttons>
        <ion-title>メモ一覧</ion-title>
      </ion-toolbar>
    </ion-header>
    <ion-content class="content">
      <ion-list>
        <ion-item v-for="(memo, index) in memos" :key="index">
          {{memo}}
        </ion-item>
      </ion-list>
    </ion-content>
  </ion-page>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { Getter } from 'vuex-class';

@Component
export default class About extends Vue {
  @Getter('memo/memos') memos!: string[];
}
</script>

/read のrouting先です。
VuexのStoreに保存した memos を表示します。

アプリのビルド方法

下記のように capacitor.config.jsonwebDir に、ビルド結果が生成されるフォルダを指定します。

capacitor.config.json
{
  "appId": "com.example.app",
  "appName": "vue-ionic-memo",
  "bundledWebRuntime": false,
  "webDir": "dist"
}

そしてプロダクションビルドを実行したあとに package.json に書いた下記のスクリプトを実行します。

package.json
"build-ios": "npx cap sync && npx cap copy ios"

詳細についてはCapacitorの公式ドキュメント を御覧ください。

作ってみた感想

with Nuxt.js ???

開発当初は、自分の所属するオープンロジで利用しているNuxt.jsで@ionic/vueの導入を試みました。
ただVue-RouterをIonicVueRouterに置き換える際に、Nuxt.jsでは実現が困難だったため、vue-cliでやることにしました。
結果としては、世間でよく言われる「Nuxt.jsは規約/vue-cliはカスタマイズ」というメリット・デメリットを理解することができたりで、非常に勉強になりました。

@ionic/vue

ほぼvueだけでネイティブアプリっぽい動きを実現できるのは素晴らしいなと思います。
もし時間があったら、vue-nativeと比較して、双方のメリット・デメリット等を勉強してみたいと思います。

18
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?