4
3

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 3 years have passed since last update.

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

Last updated at Posted at 2020-05-27

NativeScript-Vueとは

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

image.png

RadSideDrawerとは

NativeScript Coreが提供している公式テンプレートの一つで、左からニュニュッと出るドロワーが作れる。NativeScript-Vueのドキュメントにも少しだけ紹介されている。ややこしいことに本家にもNS+Vueのドキュメントが少しだけあり、そこでもRadSideDrawerが紹介されている.そもそも追加されたのが2ヶ月前(2020-03)とかなので仕方ない。

インストール法:なぜかめちゃめちゃ探しにくいのだがここにVue用のRadSideDrawerのテンプレートが落ちている。

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

tns create my-drawer-vue --template tns-template-drawer-navigation-vue

とすればよい。

既存のプロジェクトに追加するなら、npmを参考に、

tns plugin add nativescript-ui-sidedrawer

する。テンプレートではapp.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に対応付けてこのように生成される:

<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は極めて煩雑な作りになっている:

<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を使い、コンポーネント化することで改善を図る。

デフォルトの状態

May-27-2020 12-36-35.gif

Navigatorの準備をする

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

まずはインストール

$ 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に従い、DrawerContentの子であることからDrawerContentMenuItemとした。

propsを決める

何を渡すのかを決める。アイコンはちょっと手こずったので今回はナシ…。ルート名とメニューに表示するStringは渡したい。なぜなら表示したい文言とComponent名が完全に一致することはスタイルガイドに従っていればまず起きないから。それとリンク先のページに変数を渡したい場合もある。

propsを渡す

コンポーネントのルート名とメニュー表示名。どちらもStringでそのまま指定する。
テンプレではリンク先のコンポーネント名(HomeBrowse)を渡してリンクをつくっていたが、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`についてはあとで紹介するがリンク先に何かを伝えたいときに使う。動画だとこのへんで紹介されている。必須ではないが、複数のメニューアイテムから同一のコンポーネントにリンクする場合などに必要になるため、今のうちに用意しておく。

$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で怒られた。SOでの助言に従い、routes.jscomponent: 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

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

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

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

4
3
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
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?