Skip to content

Instantly share code, notes, and snippets.

@lqt0223
Last active November 15, 2017 14:41
Show Gist options
  • Select an option

  • Save lqt0223/1d235d124dff6198a875b2a74f85813d to your computer and use it in GitHub Desktop.

Select an option

Save lqt0223/1d235d124dff6198a875b2a74f85813d to your computer and use it in GitHub Desktop.
vue navigator demo

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment