A navigator component written in Vue
- Use it with vue-router...
- When navigating, any new view is pushed into view stack.
- You can specify 'main' routes, whose view will not be stacked.
- Transition is not implemented. Please use transition hook methods.
Usage example:
<template>
<div id="app">
<p>This is a navigator</p>
<p class="small">In this example:</p>
<p class="small">view1 and view3 is in main stage</p>
<p class="small">view2 and view4 is in sub stage</p>
<router-link :to="{ path: '/view1' }">view1</router-link>
<router-link :to="{ path: '/view2' }">view2</router-link>
<router-link :to="{ path: '/view3' }">view3</router-link>
<router-link :to="{ path: '/view4' }">view4</router-link>
<navigator
:main="['view1', 'view3']"
:on-before-enter="onBeforeEnter"
:onEnter="onEnter"
:on-before-leave="onBeforeLeave"
:on-leave="onLeave"
></navigator>
</div>
</template>The component:
<script>
/*
The navigator accept a 'main' prop, which receives an array containing route
paths that should not be 'stacked' while navigating.
A stacked page will be shown in sub stage, which will slide in from right when initialized,
and the page will be initialized every time.
An un-stacked page will be shown in main stage, which will have no animation when initialized,
and the page will be cached
basic rules:
- from main
- to main
- replace the page on main view
- to sub
- push a new page to stack
- form sub
- to main
- replace the page on main view, then pop every pages in stack and move to main
- to sub
- if the sub page to navigate is visited, pop every pages between current and the sub page and move to the sub page
- else, push a new page to stack
*/
export default {
name: 'navigator',
props: {
main: {
type: Array
},
onBeforeEnter: {
type: Function
},
onBeforeLeave: {
type: Function
},
onEnter: {
type: Function
},
onLeave: {
type: Function
}
},
created() {
this.fromRoute = ''
this.toRoute = ''
this.cache = {}
this.history = []
},
methods: {
// util functions
getNavigatorPageId(key) {
return 'navigator-page-' + key
},
isMain(path) {
return this.main.indexOf(path) > -1
},
randomStr() {
const possible = '0123456789abcdef'
let str = ''
for (var i = 0; i < 4; i++) {
const index = Math.floor(Math.random() * possible.length)
str += possible[index]
}
return str
},
renderMatchedRouteComponent() {
const component = this.$route.matched[0].components.default
return this.$createElement(component)
},
wrap(cached, routeName) {
return this.$createElement('div', {
class: 'navigator-page',
key: cached.key, // essential! a specified key will enable vue patcher to find the exact cached patcher
attrs: {
id: this.getNavigatorPageId(routeName),
route: routeName
}
}, [cached.vNode])
},
clearSubCaches(toRoute) {
for (var routeName in this.cache) {
if (this.cache.hasOwnProperty(routeName)) {
if (!this.isMain(routeName) && toRoute !== routeName) {
delete this.cache[routeName]
}
}
}
},
// class toggle functions
addClass(key, className) {
const dom = document.querySelector('#' + this.getNavigatorPageId(key))
if (dom.className) {
if (dom.className.indexOf('navigator-' + className) < 0) {
dom.className += ' navigator-' + className
}
} else {
dom.className = 'navigator-' + className
}
},
removeClass(key, className) {
const dom = document.querySelector('#' + this.getNavigatorPageId(key))
dom.className = dom.className.replace('navigator-' + className, '').replace(/ {2,}/, '').replace(/ +$/, '')
},
// handler functions
mainToMain(vNodes, toRoute) {
for (var i = 0; i < vNodes.length; i++) {
const vNode = vNodes[i]
// move matched vNode to the end of the vNode array
if (vNode.data.attrs.route === toRoute) {
vNodes.splice(i, 1)
vNodes.push(vNode)
break
}
}
setTimeout(() => {
const toDom = document.querySelector('#' + this.getNavigatorPageId(toRoute))
this.onEnter(toDom)
}, 0);
},
mainToSub(vNodes, fromRoute, toRoute) {
// FIXME: fix a occasional bug when the from vnode is reordered by vue patch engine
for (var i = 0; i < vNodes.length; i++) {
const vNode = vNodes[i]
// move matched vNode to the end of the vNode array
if (vNode.data.attrs.route === fromRoute) {
vNodes.splice(i, 1)
vNodes.push(vNode)
break
}
}
setTimeout(() => {
const fromDom = document.querySelector('#' + this.getNavigatorPageId(fromRoute))
const toDom = document.querySelector('#' + this.getNavigatorPageId(toRoute))
this.onBeforeLeave(fromDom)
setTimeout(() => {
this.onLeave(fromDom)
}, 50);
this.onBeforeEnter(toDom)
setTimeout(() => {
this.onEnter(toDom)
}, 50);
}, 0);
},
subToMain(vNodes, fromRoute, toRoute) {
// use mainToMain to reorder main stage content
this.mainToMain(vNodes, toRoute)
// then, call transition handler in reverse
setTimeout(() => {
const fromDom = document.querySelector('#' + this.getNavigatorPageId(fromRoute))
const toDom = document.querySelector('#' + this.getNavigatorPageId(toRoute))
this.onLeave(toDom)
setTimeout(() => {
this.onBeforeLeave(toDom)
}, 50);
this.onEnter(fromDom)
setTimeout(() => {
this.onBeforeEnter(fromDom)
}, 50);
}, 0);
},
subToSub(vNodes, fromRoute, toRoute) {
this.mainToSub(vNodes, fromRoute, toRoute)
},
// from sub page to a visited sub page
subToVisited(vNode, fromRoute, toRoute) {
setTimeout(() => {
const fromDom = document.querySelector('#' + this.getNavigatorPageId(fromRoute))
const toDom = document.querySelector('#' + this.getNavigatorPageId(toRoute))
this.onLeave(toDom)
setTimeout(() => {
this.onBeforeLeave(toDom)
}, 50);
this.onEnter(fromDom)
setTimeout(() => {
this.onBeforeEnter(fromDom)
}, 50);
}, 0);
}
},
render(h) {
this.toRoute = this.$route.path.replace(/\//g, '')
const routeName = this.$route.matched[0].path.replace(/\//g, '')
const component = this.$route.matched[0].components.default
// if the vNode is not cached, render the new vNode and cache it
if (!this.cache[routeName]) {
const routeVNode = this.renderMatchedRouteComponent(component)
this.cache[routeName] = {
vNode: routeVNode,
key: this.randomStr()
}
}
// wrap cached vNode with a div
let childVNodes = []
for (var route in this.cache) {
if (this.cache.hasOwnProperty(route)) {
const wrapped = this.wrap(this.cache[route], route)
childVNodes.push(wrapped)
}
}
// handle different navigation cases.
// if no from route (initial render)
if (!this.fromRoute) {
// to main stage
if (this.isMain(this.toRoute)) {
this.history = [this.toRoute]
// to sub stage
}
} else {
// from main stage
if (this.isMain(this.fromRoute)) {
// to main stage
if (this.isMain(this.toRoute)) {
this.mainToMain(childVNodes, this.toRoute)
this.history = [this.toRoute] // truncate history to only main stage item
this.clearSubCaches() // truncate cached vnodes for sub stage
// to sub stage
} else {
this.mainToSub(childVNodes, this.fromRoute, this.toRoute)
this.history.push(this.toRoute)
}
// from sub stage
} else {
// to main stage
if (this.isMain(this.toRoute)) {
this.subToMain(childVNodes, this.fromRoute, this.toRoute)
this.history = [this.toRoute] // truncate history to only main stage item
this.clearSubCaches() // truncate cached vnode fors sub stage
// to sub stage
} else {
// if the sub to navigate is already visited
if (this.history.indexOf(this.toRoute) > -1) {
this.subToVisited(childVNodes, this.fromRoute, this.toRoute)
const i = this.history.indexOf(this.toRoute)
this.history.splice(i + 1) // truncate history to current page
// truncate cached vnodes, leaving nodes for main stage and current page
this.clearSubCaches(this.toRoute)
} else {
this.subToSub(childVNodes, this.fromRoute, this.toRoute)
this.history.push(this.toRoute)
}
}
}
}
// update route record
this.fromRoute = this.toRoute
// return composed vNodes to render
return h('div', {
class: 'navigator'
}, childVNodes)
}
}
</script>
<style>
.navigator-page {
position: absolute;
top: 0px;
left: 0px;
width: 100%;
height: 400px;
}
.navigator {
position: relative;
top: 100px;
width: 100%;
height: 1000px;
overflow: hidden;
}
</style>