NativeScript-Vueとは
Vue.jsで作ったWebページのロジックを流用しながらネイティブアプリをNativeScriptで書く方法。Vue Nativeよりも開発が盛んでWeexよりもオープンなプラットフォームらしい。NativeScriptのXML記法を新たに学んでテンプレートを置き換えていかなければならないがとてもかんたんで良い。NativeScript Coreという親の下に、Angular, React, Vue, Svelteへの応用がある。
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://"
></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="" 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="" 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="" 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="" 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="" 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を使い、コンポーネント化することで改善を図る。
デフォルトの状態
Navigatorの準備をする
さっそくメニューアイテムのコンポーネント化をしたいのだが、Navigatorで必要となる引数(いわばリンク先を知らせたい)をコンポーネントにprops
として渡したいので、Navigatorをまずは導入する。Github Repoに加え、数日前につくられたNativeScriptingのオッチャンとNS-Vueの作者によるチュートリアル動画が大変参考になる。英語だけど…
まずはインストール
$ npm install --save nativescript-vue-navigator
main.jsでインポートして使う。navigatorのページだと以下のようにrendering functionが書かれているが、
new Vue({
- render: h => h('frame', App),
+ render: h => h(App),
}).$start()
我々はRadSideDrawerを使うのでRender functionは一文字も変えない。とにかく
+ import Navigator from 'nativescript-vue-navigator'
+ import {routes} from './routes'
+ Vue.use(Navigator, { routes })
の部分だけやればよい。
router.js
に似たroutes.js
を追加する。たとえばこんな感じというのがNavigatorのページに書かれているが、今回はRadSideDrawerのテンプレとの整合性で次のようにする。
import Home from './components/Home'
import Browse from './components/Browse'
export const routes = {
'/home': {
component: Home,
},
'/browse': {
component: Browse,
},
}
そしてドキュメントを読むとApp.vue
に<Navigator>
を追加してね、
<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でそのまま指定する。
テンプレではリンク先のコンポーネント名(Home
やBrowse
)を渡してリンクをつくっていたが、navigatorではルート名(routes.js
で定義した'/home'
や'/browse'
)を渡す必要がある。コンポーネント名とかぶってわからなくなるとやっかいなのですぐにわかるようにふざけた文言にした。
...
<DrawerContentMenuItem
route="/home"
name="Home dayo"
/>
...
propsを受け取る
次のように受け取る。どちらのpropsも必須でいいと思う。
...
<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のドキュメントに従い、次のようにリンクを貼ることができる。
...
<script>
...
methods: {
onNavigationItemTap(route) {
this.$navigator.navigate(route)
utils.closeDrawer()
},
},
...
</script>
でも色々プロパティを渡したいよね、ということで拡張したのが以下↓
...
<script>
...
methods: {
onNavigationItemTap(route) {
this.$navigator.navigate(route, {
props: this.props,
clearHistory: true,
})
utils.closeDrawer()
},
},
...
</script>
SelectedPageServiceまわりの実装を移植
訪れているページのメニューアイテムを青く塗るための工夫。
コンポーネント名をルート名から割り出…せない!
routes.js
にコンポーネント名:ルート名のコンビが保管されているので照会するのも楽勝だろうと思っていたらそんなことはできないとSOで怒られた。SOでの助言に従い、routes.js
にcomponent: Home
に加えてmeta: {name: 'Home'}
を登録しておく。meta
にしたのはNavigatorのページにGood Practiceと書いてあったため。
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名から簡単に割り出せるようになる。
...
<script>
import { routes } from '~/routes'
...
export default {
computed: {
component() {
return routes[this.route].meta.name
},
}
}
</script>
リンク先コンポーネント名とselectedPageをもとにクラスをスイッチする
selectedPage
はなんのためにあるかというと適用するCSSのクラスをスイッチするだけなのである。簡単に言うと「このメニューアイテムのリンク先のコンポーネント名と現在のselectedPageが一緒だったら青く、そうでなければ白く塗ってね」としている。**Vuexでやればよいのでは…?**と思わなくもないがよくわからないのでそのままデフォルトのものを使う。テンプレだと少しわかりづらかったので次のようにした。関連する行だけ抜き出してみると、
<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>
を次のように書き換える:
...
<Navigator :defaultRoute="'/home'" ~mainContent ref="drawerMainContent">
<slot name="mainContent"></slot>
</Navigator>
...
:defaultRoute="'route-name'"
は必須。これがオプショナルだと思ってだいぶ時間を溶かした。
最終的にこうなる
これらの変更をすべて加えると以下のようになる。
<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>
<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を渡す。
<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://"
></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を受け取ってテキストとしてレンダリングしてみる:
<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=""></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>
動いている様子
ふたつとも青くなっちゃったりしてるけどまぁやりたいことはできた
Github repo: https://github.com/xerroxcopy/rad-side-navigator
間違っていたり改善できるポイントがあれば教えて下さい