NativeScript-Vueでサイドドロワーをつくる：RadSideDrawerのメニューアイテムのコンポーネント化とNavigator化

# NativeScript-Vueとは

Vue.jsで作ったWebページのロジックを流用しながらネイティブアプリをNativeScriptで書く方法。Vue Nativeよりも開発が盛んでWeexよりもオープンなプラットフォームらしい。NativeScriptのXML記法を新たに学んでテンプレートを置き換えていかなければならないがとてもかんたんで良い。NativeScript Coreという親の下に、Angular, React, Vue, Svelteへの応用がある。

<img width="863" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152050/7545f0b4-bb6e-8f2d-890c-ec1113456a1e.png">

## RadSideDrawerとは

NativeScript Coreが提供している[公式テンプレート](https://docs.nativescript.org/app-templates/app-templates#navigation-drawer)の一つで、左からニュニュッと出るドロワーが作れる。[NativeScript-Vueのドキュメントにも少しだけ紹介されている](https://nativescript-vue.org/en/docs/utilities/v-view/#example-radsidedrawer)。ややこしいことに[本家にもNS+Vueのドキュメントが少しだけあり、そこでもRadSideDrawerが紹介されている](https://docs.nativescript.org/vuejs/ns-ui/SideDrawer/getting-started).そもそも追加されたのが２ヶ月前（2020-03）とかなので仕方ない。


インストール法：なぜかめちゃめちゃ探しにくいのだが[ここ](https://market.nativescript.org/plugins/tns-template-drawer-navigation-vue)にVue用のRadSideDrawerのテンプレートが落ちている。

まっさらなものをつくるなら、`my-drawer-vue`というプロジェクト名であれば

```
tns create my-drawer-vue --template tns-template-drawer-navigation-vue
```
とすればよい。

既存のプロジェクトに追加するなら、[npm](https://www.npmjs.com/package/nativescript-ui-sidedrawer)を参考に、

```
tns plugin add nativescript-ui-sidedrawer
```

する。テンプレートでは`app.js`が

```js
import Vue from 'nativescript-vue'
import App from './components/App'
import Home from './components/Home'
import DrawerContent from './components/DrawerContent'
import RadSideDrawer from 'nativescript-ui-sidedrawer/vue'

Vue.use(RadSideDrawer)

Vue.config.silent = TNS_ENV === 'production'

new Vue({
  render(h) {
    return h(App, [
      h(DrawerContent, { slot: 'drawerContent' }),
      h(Home, { slot: 'mainContent' }),
    ])
  },
}).$start()

```
となっているので、これを自らのアプリの`main.js`（コードシェアリングを使っていれば`main.native.js`）などに適用すればよい。

`App.vue`では、上記で定義したrendering functionに対応付けてこのように生成される：

```js
<template lang="html">
  <RadSideDrawer ref="drawer" drawerLocation="Left" gesturesEnabled="true" :drawerTransition="transition">
    <StackLayout ~drawerContent backgroundColor="#ffffff">
      <slot name="drawerContent"></slot>
    </StackLayout>
    <Frame ~mainContent ref="drawerMainContent">
      <slot name="mainContent"></slot>
    </Frame>
  </RadSideDrawer>
</template>

<script>
    import { SlideInOnTopTransition } from 'nativescript-ui-sidedrawer';
    
    export default {
        data () {
          return {
            transition: new SlideInOnTopTransition()
          }
        }
  }
</script>

<style scoped lang="scss">
    // Start custom common variables
    @import '~@nativescript/theme/scss/variables/blue';
    // End custom common variables

    // Custom styles
</style>

```

このニョロ`~mainContent`と`~drawerContent`がミソで、`v-view`というNativeScript-Vue特有の方法で「この部分はこっちにレンダリングしてね」というのを指定しているようだ。

しかしデフォルトの`DrawerContent.vue`は極めて煩雑な作りになっている：

```js
<template lang="html">
  <GridLayout rows="auto, *" class="nt-drawer__content">
    <StackLayout row="0" class="nt-drawer__header">
      <Image
        class="nt-drawer__header-image fas t-36"
        src.decode="font://&#xf2bd;"
      ></Image>
      <Label class="nt-drawer__header-brand" text="User Name"></Label>
      <Label
        class="nt-drawer__header-footnote"
        text="username@mail.com"
      ></Label>
    </StackLayout>

    <ScrollView row="1" class="nt-drawer__body">
      <StackLayout>
        <GridLayout
          columns="auto, *"
          :class="
            'nt-drawer__list-item' +
            (selectedPage === 'Home' ? ' -selected' : '')
          "
          @tap="onNavigationItemTap(Home)"
        >
          <Label col="0" text.decode="&#xf015;" class="nt-icon fas"></Label>
          <Label col="1" text="Home" class="p-r-10"></Label>
        </GridLayout>

        <GridLayout
          columns="auto, *"
          :class="
            'nt-drawer__list-item' +
            (selectedPage === 'Browse' ? ' -selected' : '')
          "
          @tap="onNavigationItemTap(Browse)"
        >
          <Label col="0" text.decode="&#xf1ea;" class="nt-icon far"></Label>
          <Label col="1" text="Browse" class="p-r-10"></Label>
        </GridLayout>

        <GridLayout
          columns="auto, *"
          :class="
            'nt-drawer__list-item' +
            (selectedPage === 'Search' ? ' -selected' : '')
          "
          @tap="onNavigationItemTap(Search)"
        >
          <Label col="0" text.decode="&#xf002;" class="nt-icon fas"></Label>
          <Label col="1" text="Search" class="p-r-10"></Label>
        </GridLayout>

        <GridLayout
          columns="auto, *"
          :class="
            'nt-drawer__list-item' +
            (selectedPage === 'Featured' ? ' -selected' : '')
          "
          @tap="onNavigationItemTap(Featured)"
        >
          <Label col="0" text.decode="&#xf005;" class="nt-icon fas"></Label>
          <Label col="1" text="Featured" class="p-r-10"></Label>
        </GridLayout>

        <StackLayout class="hr"></StackLayout>

        <GridLayout
          columns="auto, *"
          :class="
            'nt-drawer__list-item' +
            (selectedPage === 'Settings' ? ' -selected' : '')
          "
          @tap="onNavigationItemTap(Settings)"
        >
          <Label col="0" text.decode="&#xf013;" class="nt-icon fas"></Label>
          <Label col="1" text="Settings" class="p-r-10"></Label>
        </GridLayout>
      </StackLayout>
    </ScrollView>
  </GridLayout>
</template>

<script>
import Home from './Home'
import Browse from './Browse'
import Featured from './Featured'
import Search from './Search'
import Settings from './Settings'
import * as utils from '~/shared/utils'
import SelectedPageService from '~/shared/selected-page-service'

export default {
  mounted() {
    SelectedPageService.getInstance().selectedPage$.subscribe(
      (selectedPage) => (this.selectedPage = selectedPage)
    )
  },
  data() {
    return {
      Home: Home,
      Browse: Browse,
      Featured: Featured,
      Search: Search,
      Settings: Settings,
      selectedPage: '',
    }
  },
  components: {
    Home,
    Browse,
    Featured,
    Search,
    Settings,
  },
  methods: {
    onNavigationItemTap(component) {
      this.$navigateTo(component, {
        clearHistory: true,
      })
      utils.closeDrawer()
    },
  },
}
</script>

<style scoped lang="scss">
// Start custom common variables
@import '~@nativescript/theme/scss/variables/blue';
// End custom common variables

// Custom styles
</style>
```

使いにくいなと思った点：

- ひとつひとつのメニューアイテムがコンポーネント化されていないため繰り返しの多いテンプレートになっている
- リンク先のコンポーネントをいちいち追加する必要があり煩雑

これらの問題を、[Navigator](https://github.com/nativescript-vue/nativescript-vue-navigator)を使い、コンポーネント化することで改善を図る。

## デフォルトの状態

![May-27-2020 12-36-35.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152050/58c9b9e4-d6f8-0e6a-f69c-412e26a55dd5.gif)

## Navigatorの準備をする

さっそくメニューアイテムのコンポーネント化をしたいのだが、Navigatorで必要となる引数（いわばリンク先を知らせたい）をコンポーネントに`props`として渡したいので、Navigatorをまずは導入する。[Github Repo](https://github.com/nativescript-vue/nativescript-vue-navigator)に加え、数日前につくられたNativeScriptingのオッチャンとNS-Vueの作者による[チュートリアル動画](https://www.youtube.com/watch?v=TAzQ2_8lFdY)が大変参考になる。英語だけど…

まずはインストール

```
$ npm install --save nativescript-vue-navigator
```

main.jsでインポートして使う。navigatorのページだと以下のようにrendering functionが書かれているが、

```main.js
new Vue({
-   render: h => h('frame', App),
+   render: h => h(App),
}).$start()
```

我々はRadSideDrawerを使うのでRender functionは一文字も変えない。とにかく

```main.js
+ import Navigator from 'nativescript-vue-navigator'
+ import {routes} from './routes'
+ Vue.use(Navigator, { routes })
```

の部分だけやればよい。

`router.js`に似た`routes.js`を追加する。たとえばこんな感じというのがNavigatorのページに書かれているが、今回はRadSideDrawerのテンプレとの整合性で次のようにする。

```routes.js
import Home from './components/Home'
import Browse from './components/Browse'

export const routes = {
  '/home': {
    component: Home,
  },
  '/browse': {
    component: Browse,
  },
}
```

そしてドキュメントを読むと`App.vue`に`<Navigator>`を追加してね、

```App.vue
<template>
+  <Navigator :defaultRoute="isLoggedIn ? '/home' : '/login'"/>
</template>
```

というのと、manual routingの`this.$navigateTo(Component)`ではなく`this.$navigator.navigate('/component')`を使ってね、ということを頭に留めておく。前者はかなりあとになって対応が必要になる。
これで準備は終わり。コンポーネント化をしよう。

## メニューアイテムのコンポーネント化

- 名前を決める
- propsを決める
- propsを渡す
- propsを受け取る
- navigatorに置き換える
という流れで行こう。

### 子コンポーネントの名前を決める

案外重要だと思っている。[Style Guideに従い](https://jp.vuejs.org/v2/style-guide/index.html#%E5%AF%86%E7%B5%90%E5%90%88%E3%82%B3%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%8D%E3%83%B3%E3%83%88%E3%81%AE%E5%90%8D%E5%89%8D-%E5%BC%B7%E3%81%8F%E6%8E%A8%E5%A5%A8)、DrawerContentの子であることから`DrawerContentMenuItem`とした。

### propsを決める

何を渡すのかを決める。アイコンはちょっと手こずったので今回はナシ…。ルート名とメニューに表示するStringは渡したい。なぜなら表示したい文言とComponent名が完全に一致することは[スタイルガイドに従っていればまず起きない](https://jp.vuejs.org/v2/style-guide/index.html#%E8%A4%87%E6%95%B0%E5%8D%98%E8%AA%9E%E3%82%B3%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%8D%E3%83%B3%E3%83%88%E5%90%8D-%E5%BF%85%E9%A0%88)から。それとリンク先のページに変数を渡したい場合もある。



### propsを渡す

コンポーネントのルート名とメニュー表示名。どちらもStringでそのまま指定する。
テンプレではリンク先のコンポーネント名(`Home`や`Browse`)を渡してリンクをつくっていたが、navigatorではルート名(`routes.js`で定義した`'/home'`や`'/browse'`)を渡す必要がある。コンポーネント名とかぶってわからなくなるとやっかいなのですぐにわかるようにふざけた文言にした。

```DrawerContent.vue
...
<DrawerContentMenuItem
  route="/home"
  name="Home dayo"
/>
...
```

### propsを受け取る

次のように受け取る。どちらのpropsも必須でいいと思う。

```DrawerContentMenuItem.vue
...
<script>
export default {
  props: {
    props: { type: Object, required: false }, // navigator props
    route: {
      type: String,
      required: true,
    },
    name: {
      type: String,
      required: true,
    },
  },
}
<script>
```
`props内の`props`についてはあとで紹介するがリンク先に何かを伝えたいときに使う。[動画だとこのへんで紹介されている](https://youtu.be/TAzQ2_8lFdY?t=867)。必須ではないが、複数のメニューアイテムから同一のコンポーネントにリンクする場合などに必要になるため、今のうちに用意しておく。



## $navigateToを$navigatorに置き換える

navigatorを使えばリンク先のコンポーネントを全部importする必要がなくなる。Navigatorのドキュメントに従い、次のようにリンクを貼ることができる。

```DrawerContentMenuItem.vue
...
<script>
...
methods: {
    onNavigationItemTap(route) {
      this.$navigator.navigate(route)
      utils.closeDrawer()
    },
  },
...
</script>
```

でも色々プロパティを渡したいよね、ということで拡張したのが以下↓

```DrawerContentMenuItem.vue
...
<script>
...
methods: {
    onNavigationItemTap(route) {
      this.$navigator.navigate(route, {
        props: this.props,
        clearHistory: true,
      })
      utils.closeDrawer()
    },
  },
...
</script>
```


## SelectedPageServiceまわりの実装を移植

訪れているページのメニューアイテムを青く塗るための工夫。

### コンポーネント名をルート名から割り出…せない！

`routes.js`にコンポーネント名：ルート名のコンビが保管されているので照会するのも楽勝だろうと思っていたら[そんなことはできないとSOで怒られた](https://stackoverflow.com/questions/62036993/extract-object-name-as-string-from-an-object-in-javascript)。SOでの助言に従い、`routes.js`に`component: Home`に加えて`meta: {name: 'Home'}`を登録しておく。`meta`にしたのはNavigatorのページにGood Practiceと書いてあったため。

```routes.js
import Home from './components/Home'
import Browse from './components/Browse'

export const routes = {
  '/home': {
    component: Home,
    meta: { name: 'Home' },
  },
  '/browse': {
    component: Browse,
    meta: { name: 'Browse' },
  },
}
```

この変更により、コンポーネント名がroute名から簡単に割り出せるようになる。

```DrawerContentMenuItem.vue
...
<script>
import { routes } from '~/routes'
...
export default {
  computed: {
    component() {
      return routes[this.route].meta.name
    },
  }
}
</script>
```

### リンク先コンポーネント名とselectedPageをもとにクラスをスイッチする

`selectedPage`はなんのためにあるかというと適用するCSSのクラスをスイッチするだけなのである。簡単に言うと「このメニューアイテムのリンク先のコンポーネント名と現在のselectedPageが一緒だったら青く、そうでなければ白く塗ってね」としている。**Vuexでやればよいのでは…？**と思わなくもないがよくわからないのでそのままデフォルトのものを使う。テンプレだと少しわかりづらかったので次のようにした。関連する行だけ抜き出してみると、

```DrawerContentMenuItem.vue
<template>
 <GridLayout
    columns="48, *"
    :class="
      isSelected ? 'nt-drawer__list-item -selected' : 'nt-drawer__list-item'
    "
    @tap="onNavigationItemTap(route)"
  > 
...

</template>

<script>
import SelectedPageService from '~/shared/selected-page-service'
...
export default {
  data() {
    return {
      selectedPage: '',
    }
  },
  computed: {
    isSelected() {
      return this.selectedPage === this.component
    },
  },
  mounted() {
    SelectedPageService.getInstance().selectedPage$.subscribe(
      (selectedPage) => (this.selectedPage = selectedPage)
    )
  },
 // ...
}
</script>

```



### `<Frame>`を`<Navigator>`に置き換える

これではまだクリックしたときにページが移動しない。肝心の`<Navigator>`コンポーネントで「どこに表示するか」を明示していないためである。`App.vue`の`<template>`を次のように書き換える：

```App.vue
...
    <Navigator :defaultRoute="'/home'" ~mainContent ref="drawerMainContent">
      <slot name="mainContent"></slot>
    </Navigator>
...
```
`:defaultRoute="'route-name'"`は必須。これがオプショナルだと思ってだいぶ時間を溶かした。

# 最終的にこうなる

これらの変更をすべて加えると以下のようになる。

```App.vue
<template lang="html">
  <RadSideDrawer
    ref="drawer"
    drawerLocation="Left"
    gesturesEnabled="true"
    :drawerTransition="transition"
  >
    <StackLayout ~drawerContent backgroundColor="#ffffff">
      <slot name="drawerContent"></slot>
    </StackLayout>
    <Navigator :defaultRoute="'/home'" ~mainContent ref="drawerMainContent">
      <slot name="mainContent"></slot>
    </Navigator>
  </RadSideDrawer>
</template>

<script>
import { SlideInOnTopTransition } from 'nativescript-ui-sidedrawer'

export default {
  data() {
    return {
      transition: new SlideInOnTopTransition(),
    }
  },
}
</script>

<style scoped lang="scss">
@import '~@nativescript/theme/scss/variables/blue';

</style>
```

```DrawerContentMenuItem.vue
<template native>
  <GridLayout
    columns="48, *"
    :class="
      isSelected ? 'nt-drawer__list-item -selected' : 'nt-drawer__list-item'
    "
    @tap="onNavigationItemTap(route)"
  >
    <Label col="1" :text="name" class="p-r-10"></Label>
    <Label col="1" :text="component" class="p-r-10"></Label>
  </GridLayout>
</template>

<script>
import * as utils from '~/shared/utils'
import SelectedPageService from '~/shared/selected-page-service'
import { routes } from '~/routes'

export default {
  data() {
    return {
      selectedPage: '',
    }
  },
  props: {
    props: { type: Object, required: false }, // navigator props
    route: {
      type: String,
      required: true,
    },
    name: {
      type: String,
      required: true,
    },
  },
  computed: {
    component() {
      return routes[this.route].meta.name
    },
    isSelected() {
      return this.selectedPage === this.component
    },
  },
  methods: {
    onNavigationItemTap(route) {
      this.$navigator.navigate(route, {
        props: this.props,
        clearHistory: true,
      })
      utils.closeDrawer()
    },
  },
  mounted() {
    SelectedPageService.getInstance().selectedPage$.subscribe(
      (selectedPage) => (this.selectedPage = selectedPage)
    )
  },
}
</script>

```

Navigatorのpropsを用意したのは、たとえば次のようにリンク先に変数を渡したいことがあるため。
リンク元の`DrawerContent.vue`では次のように`{query: 'books'}`などとpropsを渡す。

```DrawerContent.vue
<template lang="html">
  <GridLayout rows="auto, *" class="nt-drawer__content">
    <StackLayout row="0" class="nt-drawer__header">
      <Image
        class="nt-drawer__header-image fas t-36"
        src.decode="font://&#xf2bd;"
      ></Image>
      <Label class="nt-drawer__header-brand" text="User Name"></Label>
      <Label
        class="nt-drawer__header-footnote"
        text="username@mail.com"
      ></Label>
    </StackLayout>

    <ScrollView row="1" class="nt-drawer__body">
      <StackLayout>
        <DrawerContentMenuItem route="/home" name="Home dayo" />
        <DrawerContentMenuItem
          route="/browse"
          :props="{ query: 'books' }"
          name="Browse Books"
        />
        <DrawerContentMenuItem
          route="/browse"
          :props="{ query: 'movies' }"
          name="Browse Movies"
        />
      </StackLayout>
    </ScrollView>
  </GridLayout>
</template>

<script>
import DrawerContentMenuItem from '~/components/DrawerContentMenuItem'
export default {
  components: {
    DrawerContentMenuItem,
  },
}
</script>

<style scoped lang="scss">
@import '~@nativescript/theme/scss/variables/blue';
</style>

```

リンク先の`Browse.vue`では、このqueryを受け取ってテキストとしてレンダリングしてみる：

```Browse.vue
<template>
  <Page class="page">
    <ActionBar class="action-bar">
      <NavigationButton
        ios:visibility="collapsed"
        icon="res://menu"
        @tap="onDrawerButtonTap"
      ></NavigationButton>
      <ActionItem
        icon="res://menu"
        android:visibility="collapsed"
        @tap="onDrawerButtonTap"
        ios.position="left"
      >
      </ActionItem>
      <Label class="action-bar-title" text="Browse"></Label>
    </ActionBar>
    <GridLayout class="page__content">
      <Label class="page__content-icon far" text.decode="&#xf1ea;"></Label>
      <Label
        class="page__content-placeholder"
        :text="`Query is ${query}`"
      ></Label>
    </GridLayout>
  </Page>
</template>

<script>
import * as utils from '~/shared/utils'
import SelectedPageService from '../shared/selected-page-service'

export default {
  props: {
    query: String,
  },
  mounted() {
    SelectedPageService.getInstance().updateSelectedPage('Browse')
  },
  computed: {
    message() {
      return '<!-- Page content goes here -->'
    },
  },
  methods: {
    onDrawerButtonTap() {
      utils.showDrawer()
    },
  },
}
</script>

<style scoped lang="scss">
@import '~@nativescript/theme/scss/variables/blue';
</style>
```

動いている様子

![May-27-2020 17-05-26.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152050/4cb0597d-887a-311d-0585-9c418bf8db53.gif)

ふたつとも青くなっちゃったりしてるけどまぁやりたいことはできた

Github repo: https://github.com/xerroxcopy/rad-side-navigator

間違っていたり改善できるポイントがあれば教えて下さい