├── .babelrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── src ├── util │ ├── string.js │ ├── object.js │ ├── transition.js │ └── array.js └── navigation-controller.jsx ├── examples ├── index.html ├── index.dev.html ├── src │ ├── example.jsx │ └── view.jsx └── assets │ └── example.css ├── spec ├── util │ ├── string.spec.js │ ├── object.spec.js │ └── array.spec.js └── navigation-controller.spec.jsx ├── webpack.config.js ├── LICENSE ├── package.json ├── karma.conf.js └── README.md /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | npm-debug.log 4 | /.idea 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /examples 2 | /node_modules 3 | /spec 4 | /src 5 | karma.conf.js 6 | webpack.config.js 7 | npm-debug.log -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '6' 4 | before_install: 5 | - export CHROME_BIN=chromium-browser 6 | - export DISPLAY=:99.0 7 | - sh -e /etc/init.d/xvfb start 8 | install: 9 | - npm install 10 | script: 11 | - npm run lint 12 | - npm test 13 | -------------------------------------------------------------------------------- /src/util/string.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Capitalizes the first character of `string`. 3 | * 4 | * @param {string} [string=''] The string to capitalize. 5 | * @returns {string} Returns the capitalized string. 6 | */ 7 | export function capitalize (string) { 8 | return string && (string.charAt(0).toUpperCase() + string.slice(1)) 9 | } 10 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/index.dev.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /spec/util/string.spec.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, expect */ 2 | 3 | import { 4 | capitalize 5 | } from '../../src/util/string' 6 | 7 | describe('Util', () => { 8 | describe('String', () => { 9 | describe('#capitalize', () => { 10 | it('capitalizes the first character of a word', () => { 11 | expect(capitalize('hello')).to.equal('Hello') 12 | expect(capitalize('Hello')).to.equal('Hello') 13 | expect(capitalize('hello world')).to.equal('Hello world') 14 | }) 15 | }) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /spec/util/object.spec.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, expect */ 2 | 3 | import { 4 | assign 5 | } from '../../src/util/object' 6 | 7 | describe('Util', () => { 8 | describe('Object', () => { 9 | describe('#assign', () => { 10 | it('merges the sources into the target', () => { 11 | expect(assign({ foo: 'bar' }, { foo: 'baz' })).to.have.property('foo', 'baz') 12 | const a = assign({ foo: 'bar' }, { foo: 'baz', hello: 'world' }) 13 | expect(a).to.have.property('foo', 'baz') 14 | expect(a).to.have.property('hello', 'world') 15 | }) 16 | }) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const argv = require('minimist')(process.argv.slice(2)) 2 | 3 | const entry = { 4 | example: [ 5 | './examples/src/example.jsx' 6 | ] 7 | } 8 | 9 | if (argv.dist !== true) { 10 | entry.example.push('webpack/hot/dev-server') 11 | } 12 | 13 | module.exports = { 14 | entry, 15 | output: { 16 | path: './examples/assets', 17 | filename: '[name].js', 18 | publicPath: '/assets/' 19 | }, 20 | module: { 21 | loaders: [{ 22 | test: /\.js(x)?/, 23 | loaders: ['babel-loader'] 24 | }] 25 | }, 26 | resolve: { 27 | extensions: ['', '.js', '.jsx'] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/util/object.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Merge sources into target 3 | * 4 | * @param {object} target 5 | * @param {arguments} soutces 6 | * @return {object} 7 | */ 8 | export function assign (target, sources) { 9 | if (target == null) { 10 | throw new TypeError('Object.assign target cannot be null or undefined') 11 | } 12 | const to = Object(target) 13 | const hasOwnProperty = Object.prototype.hasOwnProperty 14 | for (var nextIndex = 1; nextIndex < arguments.length; nextIndex++) { 15 | var nextSource = arguments[nextIndex] 16 | if (nextSource == null) { 17 | continue 18 | } 19 | var from = Object(nextSource) 20 | for (var key in from) { 21 | if (hasOwnProperty.call(from, key)) { 22 | to[key] = from[key] 23 | } 24 | } 25 | } 26 | return to 27 | } 28 | -------------------------------------------------------------------------------- /src/util/transition.js: -------------------------------------------------------------------------------- 1 | export const type = { 2 | NONE: 0, 3 | PUSH_LEFT: 1, 4 | PUSH_RIGHT: 2, 5 | PUSH_UP: 3, 6 | PUSH_DOWN: 4, 7 | COVER_LEFT: 5, 8 | COVER_RIGHT: 6, 9 | COVER_UP: 7, 10 | COVER_DOWN: 8, 11 | REVEAL_LEFT: 9, 12 | REVEAL_RIGHT: 10, 13 | REVEAL_UP: 11, 14 | REVEAL_DOWN: 12 15 | } 16 | 17 | export function isPush (t) { 18 | return t === type.PUSH_LEFT || 19 | t === type.PUSH_RIGHT || 20 | t === type.PUSH_UP || 21 | t === type.PUSH_DOWN 22 | } 23 | 24 | export function isCover (t) { 25 | return t === type.COVER_LEFT || 26 | t === type.COVER_RIGHT || 27 | t === type.COVER_UP || 28 | t === type.COVER_DOWN 29 | } 30 | 31 | export function isReveal (t) { 32 | return t === type.REVEAL_LEFT || 33 | t === type.REVEAL_RIGHT || 34 | t === type.REVEAL_UP || 35 | t === type.REVEAL_DOWN 36 | } 37 | -------------------------------------------------------------------------------- /examples/src/example.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | 4 | import NavigationController from '../../src/navigation-controller' 5 | import View from './view' 6 | 7 | class App extends React.Component { 8 | render () { 9 | return ( 10 |
11 |

Single View

12 |

Start with a single view on the stack

13 | ]} 15 | preserveState 16 | transitionTension={10} 17 | transitionFriction={6} /> 18 |

Multiple Views

19 |

Start with multiple views on the stack

20 | , , ]} 22 | preserveState 23 | transitionTension={10} 24 | transitionFriction={6} /> 25 |
26 | ) 27 | } 28 | } 29 | 30 | ReactDOM.render(, document.getElementById('app')) 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Adam Putinski 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /spec/util/array.spec.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, expect */ 2 | 3 | import { 4 | dropRight, 5 | last, 6 | takeRight 7 | } from '../../src/util/array' 8 | 9 | describe('Util', () => { 10 | describe('Array', () => { 11 | describe('#dropRight', () => { 12 | it('drops the last element from the array', () => { 13 | const a = dropRight([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 14 | expect(a).to.have.length(9) 15 | expect(a[8]).to.equal(9) 16 | }) 17 | it('drops the specified number of elements from the end of the array', () => { 18 | const a = dropRight([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 2) 19 | expect(a).to.have.length(8) 20 | expect(a[7]).to.equal(8) 21 | }) 22 | }) 23 | describe('#last', () => { 24 | it('gets the last element in the array', () => { 25 | expect(last([1, 2, 3])).to.equal(3) 26 | }) 27 | }) 28 | describe('#takeRight', () => { 29 | it('get the last element from the array', () => { 30 | const a = takeRight([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 31 | expect(a).to.have.length(1) 32 | expect(a[0]).to.equal(10) 33 | }) 34 | it('gets the specified number of elements from the end of the array', () => { 35 | const a = takeRight([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 3) 36 | expect(a).to.have.length(3) 37 | expect(a[0]).to.equal(8) 38 | expect(a[1]).to.equal(9) 39 | expect(a[2]).to.equal(10) 40 | }) 41 | }) 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-navigation-controller", 3 | "version": "3.1.1", 4 | "description": "React view manager similar to UINavigationController", 5 | "keywords": [ 6 | "react", 7 | "react-component" 8 | ], 9 | "license": "MIT", 10 | "main": "dist/navigation-controller.js", 11 | "scripts": { 12 | "start": "webpack-dev-server --content-base examples/ --port 3000 --hot", 13 | "test": "./node_modules/karma/bin/karma start", 14 | "dist": "rm -rf dist && mkdir dist && babel src --out-dir dist", 15 | "dist/example": "webpack --dist", 16 | "prepublish": "npm run dist", 17 | "lint": "standard" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git@github.com:aputinski/react-navigation-controller.git" 22 | }, 23 | "author": "Adam Putinski", 24 | "dependencies": { 25 | "classnames": "2.2.5", 26 | "prop-types": "^15.5.10", 27 | "rebound": "0.0.13" 28 | }, 29 | "devDependencies": { 30 | "babel-cli": "6.11.4", 31 | "babel-core": "6.13.2", 32 | "babel-loader": "6.2.5", 33 | "babel-preset-es2015": "6.13.2", 34 | "babel-preset-react": "6.11.1", 35 | "chai": "3.5.0", 36 | "karma": "1.2.0", 37 | "karma-chai": "0.1.0", 38 | "karma-chrome-launcher": "2.0.0", 39 | "karma-mocha": "1.1.1", 40 | "karma-spec-reporter": "0.0.26", 41 | "karma-webpack": "1.8.0", 42 | "minimist": "1.2.0", 43 | "mocha": "3.0.2", 44 | "react": "15.3.1", 45 | "react-addons-test-utils": "15.3.1", 46 | "react-dom": "15.3.1", 47 | "sinon": "1.17.5", 48 | "standard": "8.0.0-beta.5", 49 | "webpack": "1.13.2", 50 | "webpack-dev-server": "1.14.1" 51 | }, 52 | "standard": { 53 | "ignore": [ 54 | "node_modules/**/*", 55 | "examples/assets/**/*" 56 | ] 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/util/array.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Adapted from LoDash 3 | * 4 | * https://lodash.com/ 5 | * 6 | * Copyright 2012-2015 The Dojo Foundation 7 | * Based on Underscore.js, copyright 2009-2015 Jeremy Ashkenas, 8 | * DocumentCloud and Investigative Reporters & Editors 9 | 10 | * Permission is hereby granted, free of charge, to any person obtaining 11 | * a copy of this software and associated documentation files (the 12 | * "Software"), to deal in the Software without restriction, including 13 | * without limitation the rights to use, copy, modify, merge, publish, 14 | * distribute, sublicense, and/or sell copies of the Software, and to 15 | * permit persons to whom the Software is furnished to do so, subject to 16 | * the following conditions: 17 | */ 18 | 19 | /** 20 | * Creates a slice of `array` with `n` elements dropped from the end. 21 | * 22 | * @param {array} array The array to query. 23 | * @param {number} [n=1] The number of elements to drop. 24 | * @returns {array} Returns the slice of `array`. 25 | */ 26 | export function dropRight (array, n = 1) { 27 | const length = array ? array.length : 0 28 | if (!length) { 29 | return [] 30 | } 31 | n = length - (+n || 0) 32 | return array.slice(0, n < 0 ? 0 : n) 33 | } 34 | 35 | /** 36 | * Gets the last element of `array`. 37 | * 38 | * @param {array} array The array to query. 39 | * @param {number} [n=1] The number of elements to drop. 40 | * @returns {*} Returns the last element of `array`. 41 | */ 42 | export function last (array) { 43 | const length = array ? array.length : 0 44 | return length ? array[length - 1] : undefined 45 | } 46 | 47 | /** 48 | * Creates a slice of `array` with `n` elements taken from the end. 49 | * 50 | * @param {array} array The array to query. 51 | * @param {number} [n=1] The number of elements to take. 52 | * @returns {array} Returns the slice of `array`. 53 | */ 54 | export function takeRight (array, n = 1) { 55 | var length = array ? array.length : 0 56 | if (!length) { 57 | return [] 58 | } 59 | n = length - (+n || 0) 60 | return array.slice(n < 0 ? 0 : n) 61 | } 62 | -------------------------------------------------------------------------------- /examples/assets/example.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 1rem; 3 | margin: 0; 4 | font-family: "Trebuchet MS","Lucida Grande","Lucida Sans Unicode","Lucida Sans",Tahoma,sans-serif; 5 | } 6 | 7 | main { 8 | padding: 1em; 9 | width: 320px; 10 | margin: 0 auto; 11 | } 12 | 13 | @media screen and (max-width: 360px) { 14 | main { 15 | width: auto; 16 | } 17 | } 18 | 19 | h2 { 20 | margin: 0 0 0.25em 0; 21 | } 22 | 23 | p { 24 | margin: 0 0 0.2em 0; 25 | } 26 | 27 | button { 28 | display: block; 29 | padding: 1em 1.5em; 30 | background-color: transparent; 31 | border: 2px solid white; 32 | border-radius: 4px; 33 | color: white; 34 | font-size: 1em; 35 | font-weight: 300; 36 | outline: none; 37 | } 38 | 39 | .ReactNavigationController { 40 | position: relative; 41 | height: 480px; 42 | margin: 1em auto 2em auto; 43 | background: #DDDDDD; 44 | overflow: hidden; 45 | } 46 | 47 | .ReactNavigationControllerView, 48 | .ReactNavigationControllerViewContent { 49 | position: absolute; 50 | top: 0; 51 | right: 0; 52 | bottom: 0; 53 | left: 0; 54 | } 55 | 56 | .ReactNavigationControllerView { 57 | display: -webkit-flex; 58 | display: -ms-flexbox; 59 | display: flex; 60 | } 61 | 62 | .ReactNavigationControllerViewContent { 63 | color: white; 64 | display: -webkit-flex; 65 | display: -ms-flexbox; 66 | display: flex; 67 | -webkit-flex-direction: column; 68 | -ms-flex-direction: column; 69 | flex-direction: column; 70 | } 71 | 72 | /* HEADER */ 73 | 74 | .ReactNavigationControllerViewContent header { 75 | display: -webkit-flex; 76 | display: -ms-flexbox; 77 | display: flex; 78 | -webkit-justify-content: space-between; 79 | -ms-flex-pack: justify; 80 | justify-content: space-between; 81 | padding: 0.75em; 82 | } 83 | 84 | .ReactNavigationControllerViewContent header button { 85 | 86 | } 87 | 88 | /* CONTENT */ 89 | 90 | .ReactNavigationControllerViewContent section { 91 | -webkit-flex: 1; 92 | -ms-flex: 1; 93 | flex: 1; 94 | display: -webkit-flex; 95 | display: -ms-flexbox; 96 | display: flex; 97 | -webkit-flex-direction: column; 98 | -ms-flex-direction: column; 99 | flex-direction: column; 100 | -webkit-align-items: center; 101 | -ms-flex-align: center; 102 | align-items: center; 103 | -webkit-justify-content: center; 104 | -ms-flex-pack: center; 105 | justify-content: center; 106 | } 107 | 108 | .ReactNavigationControllerViewContent section h3 { 109 | display: block; 110 | margin: 0 auto; 111 | font-size: 2rem; 112 | } 113 | 114 | .ReactNavigationControllerViewContent section button { 115 | margin: 1em auto; 116 | } -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | // Generated on Sat Mar 28 2015 02:55:40 GMT-0400 (EDT) 3 | 4 | module.exports = (config) => { 5 | let c = { 6 | 7 | // base path that will be used to resolve all patterns (eg. files, exclude) 8 | basePath: '', 9 | 10 | client: { 11 | mocha: { 12 | timeout: 5000 13 | } 14 | }, 15 | 16 | // frameworks to use 17 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 18 | frameworks: ['mocha', 'chai'], 19 | 20 | // list of files / patterns to load in the browser 21 | files: [ 22 | 'node_modules/sinon/pkg/sinon.js', 23 | 'spec/**/*.spec.+(jsx|js)' 24 | ], 25 | 26 | // list of files to exclude 27 | exclude: [], 28 | 29 | // preprocess matching files before serving them to the browser 30 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 31 | preprocessors: { 32 | 'spec/**/*.spec.+(jsx|js)': ['webpack'] 33 | }, 34 | 35 | // test results reporter to use 36 | // possible values: 'dots', 'progress' 37 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 38 | reporters: ['spec'], 39 | 40 | // web server port 41 | port: 9876, 42 | 43 | // enable / disable colors in the output (reporters and logs) 44 | colors: true, 45 | 46 | // level of logging 47 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 48 | logLevel: config.LOG_INFO, 49 | 50 | // enable / disable watching file and executing tests whenever any file changes 51 | autoWatch: false, 52 | 53 | // start these browsers 54 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 55 | browsers: ['Chrome'], 56 | 57 | customLaunchers: { 58 | ChromeTravis: { 59 | base: 'Chrome', 60 | flags: ['--no-sandbox'] 61 | } 62 | }, 63 | 64 | // Continuous Integration mode 65 | // if true, Karma captures browsers, runs the tests and exits 66 | singleRun: true, 67 | 68 | plugins: [ 69 | 'karma-mocha', 70 | 'karma-chai', 71 | 'karma-webpack', 72 | 'karma-spec-reporter', 73 | 'karma-chrome-launcher' 74 | ], 75 | 76 | webpack: { 77 | module: { 78 | loaders: [{ 79 | test: /\.js(x)?/, 80 | loaders: ['babel-loader'] 81 | }] 82 | }, 83 | resolve: { 84 | extensions: ['', '.js', '.jsx'] 85 | } 86 | }, 87 | 88 | webpackMiddleware: { 89 | noInfo: true 90 | } 91 | 92 | } 93 | 94 | if (process.env.TRAVIS) { 95 | c.browsers = ['ChromeTravis'] 96 | } 97 | 98 | config.set(c) 99 | } 100 | -------------------------------------------------------------------------------- /examples/src/view.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import NavigationController from '../../src/navigation-controller' 3 | 4 | const { 5 | Transition 6 | } = NavigationController 7 | 8 | const colors = [ 9 | '#0074D9', '#7FDBFF', '#39CCCC', '#2ECC40', '#FFDC00', '#FF851B', '#FF4136', 10 | '#F012BE', '#B10DC9' 11 | ] 12 | 13 | function getColor () { 14 | const color = colors.shift() 15 | colors.push(color) 16 | return color 17 | } 18 | 19 | class View extends React.Component { 20 | constructor (props) { 21 | super(props) 22 | const now = new Date() 23 | this.state = { 24 | counter: 0, 25 | color: getColor(), 26 | time: `${now.getHours()}:${now.getMinutes()}:${now.getSeconds()}` 27 | } 28 | } 29 | incrementCounter () { 30 | this.setState({ 31 | counter: this.state.counter + 1 32 | }) 33 | } 34 | onNext () { 35 | const view = 36 | this.props.navigationController.pushView(view, {}) 37 | } 38 | onBack () { 39 | this.props.navigationController.popView({ 40 | transition: this.props.modal ? Transition.type.REVEAL_DOWN : Transition.type.PUSH_RIGHT 41 | }) 42 | } 43 | onModal () { 44 | const view = 45 | this.props.navigationController.pushView(view, { 46 | transition: Transition.type.COVER_UP 47 | }) 48 | } 49 | onPopToRoot () { 50 | this.props.navigationController.popToRootView({ 51 | transition: this.props.modal ? Transition.type.REVEAL_DOWN : Transition.type.PUSH_RIGHT 52 | }) 53 | } 54 | render () { 55 | return ( 56 |
59 |
60 | {this.renderBackButton()} 61 | {this.renderNextButton()} 62 |
63 |
64 |

View {this.props.index}

65 | 68 | 71 | {this.renderPopToRootButton()} 72 |
73 |
74 | ) 75 | } 76 | renderBackButton () { 77 | const text = this.props.modal ? 'Close' : 'Back' 78 | return this.props.index === 1 79 | ?
80 | : 81 | } 82 | renderNextButton () { 83 | return this.props.modal === true 84 | ?
85 | : 86 | } 87 | renderPopToRootButton () { 88 | return this.props.index === 1 89 | ?
90 | : 91 | } 92 | } 93 | 94 | View.defaultProps = { 95 | index: 1 96 | } 97 | 98 | export default View 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React NavigationController 2 | 3 | [![Build Status][travis-image]][travis-url] 4 | [![NPM version][npm-image]][npm-url] 5 | 6 | React view manager similar to [UINavigationController][ios-controller] 7 | 8 | ## Installation 9 | 10 | ```bash 11 | npm install react-navigation-controller 12 | ``` 13 | 14 | ## Demo 15 | 16 | 17 | 18 | ## Usage 19 | 20 | ```js 21 | import React from 'react'; 22 | import NavigationController from 'react-navigation-controller'; 23 | 24 | class LoginView extends React.Component { 25 | onLogin() { 26 | this.props.navigationController.pushView( 27 |
Welcome to the app!
28 | ); 29 | } 30 | render() { 31 | return ( 32 |
33 | 36 |
37 | ); 38 | } 39 | } 40 | 41 | class App extends React.Component { 42 | render() { 43 | const props = { 44 | // The views to place in the stack. The front-to-back order 45 | // of the views in this array represents the new bottom-to-top 46 | // order of the navigation stack. Thus, the last item added to 47 | // the array becomes the top item of the navigation stack. 48 | // NOTE: This can only be updated via `setViews()` 49 | views: [ 50 | 51 | ], 52 | 53 | // If set to true, the navigation will save the state of each view that 54 | // pushed onto the stack. When `popView()` is called, the navigationController 55 | // will rehydrate the state of the view before it is shown. 56 | // Defaults to false 57 | // NOTE: This can only be updated via `setViews()` 58 | preserveState: true, 59 | 60 | // The spring tension for transitions 61 | // http://facebook.github.io/rebound-js/docs/rebound.html 62 | // Defaults to 10 63 | transitionTension: 12, 64 | 65 | // The spring friction for transitions 66 | // Defaults to 6 67 | transitionFriction: 5 68 | }; 69 | return ( 70 | 71 | ); 72 | } 73 | } 74 | ``` 75 | 76 | ## API 77 | 78 | Once a view is pushed onto the stack, it will recieve a `navigationController` prop 79 | with the following methods: 80 | 81 | ### `pushView(view, [options])` 82 | 83 | Push a new view onto the stack 84 | 85 | **Arguments** 86 | 87 | ##### `view` `{ReactElement}` 88 | 89 | Any valid React element (`React.PropTypes.element`) 90 | 91 | ##### `options` `{object}` 92 | 93 | Addtional options 94 | 95 | ##### `options.transiton` `{number|function}` `default=Transition.type.PUSH_LEFT` 96 | 97 | Specify the type of transition: 98 | 99 | ```js 100 | NavigationController.Transition.type = { 101 | NONE: 0, 102 | PUSH_LEFT: 1, 103 | PUSH_RIGHT: 2, 104 | PUSH_UP: 3, 105 | PUSH_DOWN: 4, 106 | COVER_LEFT: 5, 107 | COVER_RIGHT: 6, 108 | COVER_UP: 7, 109 | COVER_DOWN: 8, 110 | REVEAL_LEFT: 9, 111 | REVEAL_RIGHT: 10, 112 | REVEAL_UP: 11, 113 | REVEAL_DOWN: 12 114 | }; 115 | ``` 116 | 117 | A function can be used to perform custom transitions: 118 | 119 | ```jsx 120 | navigationController.pushView(, { 121 | transition(prevElement, nextElement, done) { 122 | // Do some sort of animation on the views 123 | prevElement.style.transform = 'translate(100%, 0)'; 124 | nextElement.style.transform = 'translate(0, 0)'; 125 | // Tell the navigationController when the animation is complete 126 | setTimeout(done, 500); 127 | } 128 | }); 129 | ``` 130 | 131 | ##### `options.transitonTension` `{number}` `default=10` 132 | 133 | Specify the spring tension to be used for built-in animations 134 | 135 | ##### `options.transitonFriction` `{number}` `default=6` 136 | 137 | Specify the spring friction to be used for built-in animations 138 | 139 | ##### `options.onComplete` `{function}` 140 | 141 | Called once the transition has completed 142 | 143 | *** 144 | 145 | ### `popView([options])` 146 | 147 | Pop the last view off the stack 148 | 149 | **Arguments** 150 | 151 | ##### `options` `{object}` 152 | 153 | Addtional options - see [pushView()](#push-options) 154 | 155 | ##### `options.transiton` `{number|function}` `default=Transition.type.PUSH_RIGHT` 156 | 157 | *** 158 | 159 | ### `popToRootView([options])` 160 | 161 | Pop the all the views off the stack except the first (root) view 162 | 163 | **Arguments** 164 | 165 | ##### `options` `{object}` 166 | 167 | Addtional options - see [pushView()](#push-options) 168 | 169 | ##### `options.transiton` `{number|function}` `default=Transition.type.PUSH_RIGHT` 170 | 171 | *** 172 | 173 | ### `setViews(views, [options])` 174 | 175 | Replaces the views currently managed by the navigationController 176 | with the specified views 177 | 178 | **Arguments** 179 | 180 | ##### `views` `{[ReactElement]}` 181 | 182 | The views to place in the stack. The front-to-back order of the 183 | views in this array represents the new bottom-to-top order of the views 184 | in the navigation stack. Thus, the last view added to the array 185 | becomes the top item of the navigation stack. 186 | 187 | ##### `options` `{object}` 188 | 189 | Addtional options - see [pushView()](#push-options) 190 | 191 | ##### `options.preserveState` `{boolean}` `default=false` 192 | 193 | If set to `true`, the navigationController will save the state 194 | of each view that gets pushed onto the stack. When `popView()` is called, 195 | the navigationController will rehydrate the state of the view before it is shown. 196 | 197 | ## Lifecycle Events 198 | 199 | Similar to the React component lifecycle, the navigationController will 200 | call lifecycle events on the component at certain stages. 201 | 202 | Lifecycle events can trigger actions when views transition in or out, 203 | instead of mounted or unmounted: 204 | 205 | ```javascript 206 | class HelloView extends React.Component { 207 | navigationControllerDidShowView() { 208 | // Do something when the show transition is finished, 209 | // like fade in an element. 210 | } 211 | navigationControllerWillHideView() { 212 | // Do something when the hide transition will start, 213 | // like fade out an element. 214 | } 215 | render() { 216 | return
Hello, {this.props.name}!
; 217 | } 218 | } 219 | ``` 220 | 221 | ### `view.navigationControllerWillHideView()` 222 | 223 | Invoked immediately before the previous view will be hidden. 224 | 225 | ### `view.navigationControllerWillShowView()` 226 | 227 | Invoked immediately before the next view will be shown. 228 | 229 | ### `view.navigationControllerDidHideView()` 230 | 231 | Invoked immediately after the previous view has been hidden. 232 | 233 | ### `view.navigationControllerDidShowView()` 234 | 235 | Invoked immediately after the next view has been shown. 236 | 237 | ## Styling 238 | 239 | No default styles are provided, but classes are added for custom styling: 240 | 241 | ```html 242 |
243 | 244 |
245 | 246 |
247 |
248 | ``` 249 | 250 | Check out the examples for some basic CSS. 251 | 252 | ## Dev 253 | 254 | ```bash 255 | npm install 256 | npm start 257 | ``` 258 | 259 | Visit [http://localhost:3000/index.dev.html]() 260 | 261 | [ios-controller]: https://developer.apple.com/library/ios/documentation/UIKit/Reference/UINavigationController_Class/ 262 | 263 | [npm-url]: https://npmjs.org/package/react-navigation-controller 264 | [npm-image]: http://img.shields.io/npm/v/react-navigation-controller.svg 265 | 266 | [travis-url]: https://travis-ci.org/aputinski/react-navigation-controller 267 | [travis-image]: http://img.shields.io/travis/aputinski/react-navigation-controller.svg 268 | 269 | -------------------------------------------------------------------------------- /src/navigation-controller.jsx: -------------------------------------------------------------------------------- 1 | /* global requestAnimationFrame */ 2 | 3 | import React from 'react' 4 | import PropTypes from 'prop-types' 5 | import rebound from 'rebound' 6 | import classNames from 'classnames' 7 | 8 | import { dropRight, last, takeRight } from './util/array' 9 | import { assign } from './util/object' 10 | 11 | import * as Transition from './util/transition' 12 | 13 | const { 14 | SpringSystem, 15 | SpringConfig, 16 | OrigamiValueConverter 17 | } = rebound 18 | 19 | const { 20 | mapValueInRange 21 | } = rebound.MathUtil 22 | 23 | const isNumber = (value) => 24 | typeof value === 'number' 25 | const isFunction = (value) => 26 | typeof value === 'function' 27 | const isBool = (value) => 28 | value === true || value === false 29 | const isArray = (value) => 30 | Array.isArray(value) 31 | 32 | const validate = (validator) => (options, key, method) => { 33 | if (!validator(options[key])) { 34 | throw new Error(`Option "${key}" of method "${method}" was invalid`) 35 | } 36 | } 37 | 38 | const optionTypes = { 39 | pushView: { 40 | view: validate(React.isValidElement), 41 | transition: validate(x => isFunction(x) || isNumber(x)), 42 | onComplete: validate(isFunction) 43 | }, 44 | popView: { 45 | transition: validate(x => isFunction(x) || isNumber(x)), 46 | onComplete: validate(isFunction) 47 | }, 48 | popToRootView: { 49 | transition: validate(x => isFunction(x) || isNumber(x)), 50 | onComplete: validate(isFunction) 51 | }, 52 | setViews: { 53 | views: validate(x => isArray(x) && x.reduce((valid, e) => { 54 | return valid === false ? false : React.isValidElement(e) 55 | }, true) === true), 56 | preserveState: validate(isBool), 57 | transition: validate(x => isFunction(x) || isNumber(x)), 58 | onComplete: validate(isFunction) 59 | } 60 | } 61 | 62 | /** 63 | * Validate the options passed into a method 64 | * 65 | * @param {string} method - The name of the method to validate 66 | * @param {object} options - The options that were passed to "method" 67 | */ 68 | function checkOptions (method, options) { 69 | const optionType = optionTypes[method] 70 | Object.keys(options).forEach(key => { 71 | if (optionType[key]) { 72 | const e = optionType[key](options, key, method) 73 | if (e) throw e 74 | } 75 | }) 76 | } 77 | 78 | class NavigationController extends React.Component { 79 | 80 | constructor (props) { 81 | super(props) 82 | const { views, preserveState } = this.props 83 | this.state = { 84 | views: dropRight(views), 85 | preserveState, 86 | mountedViews: [] 87 | } 88 | // React no longer auto binds 89 | const methods = ['__onSpringUpdate', '__onSpringAtRest'] 90 | methods.forEach(method => { 91 | this[method] = this[method].bind(this) 92 | }) 93 | } 94 | 95 | componentWillMount () { 96 | this.__isTransitioning = false 97 | this.__viewStates = [] 98 | this.__viewIndexes = [0, 1] 99 | this.__springSystem = new SpringSystem() 100 | this.__spring = this.__springSystem.createSpring( 101 | this.props.transitionTension, 102 | this.props.transitionFriction 103 | ) 104 | this.__spring.addListener({ 105 | onSpringUpdate: this.__onSpringUpdate.bind(this), 106 | onSpringAtRest: this.__onSpringAtRest.bind(this) 107 | }) 108 | } 109 | 110 | componentWillUnmount () { 111 | delete this.__springSystem 112 | this.__spring.removeAllListeners() 113 | delete this.__spring 114 | } 115 | 116 | componentDidMount () { 117 | // Position the wrappers 118 | this.__transformViews(0, 0, -100, 0) 119 | // Push the last view 120 | this.pushView(last(this.props.views), { 121 | transition: Transition.type.NONE 122 | }) 123 | } 124 | 125 | /** 126 | * Translate the view wrappers by a specified percentage 127 | * 128 | * @param {number} prevX 129 | * @param {number} prevY 130 | * @param {number} nextX 131 | * @param {number} nextY 132 | */ 133 | __transformViews (prevX, prevY, nextX, nextY) { 134 | const [prev, next] = this.__viewIndexes 135 | const prevView = this.refs[`view-wrapper-${prev}`] 136 | const nextView = this.refs[`view-wrapper-${next}`] 137 | requestAnimationFrame(() => { 138 | prevView.style.transform = `translate(${prevX}%,${prevY}%)` 139 | prevView.style.zIndex = Transition.isReveal(this.state.transition) ? 1 : 0 140 | nextView.style.transform = `translate(${nextX}%,${nextY}%)` 141 | nextView.style.zIndex = Transition.isReveal(this.state.transition) ? 0 : 1 142 | }) 143 | } 144 | 145 | /** 146 | * Map a 0-1 value to a percentage for __transformViews() 147 | * 148 | * @param {number} value 149 | * @param {string} [transition] - The transition type 150 | * @return {array} 151 | */ 152 | __animateViews (value = 0, transition = Transition.type.NONE) { 153 | let prevX = 0 154 | let prevY = 0 155 | let nextX = 0 156 | let nextY = 0 157 | switch (transition) { 158 | case Transition.type.NONE: 159 | case Transition.type.PUSH_LEFT: 160 | prevX = mapValueInRange(value, 0, 1, 0, -100) 161 | nextX = mapValueInRange(value, 0, 1, 100, 0) 162 | break 163 | case Transition.type.PUSH_RIGHT: 164 | prevX = mapValueInRange(value, 0, 1, 0, 100) 165 | nextX = mapValueInRange(value, 0, 1, -100, 0) 166 | break 167 | case Transition.type.PUSH_UP: 168 | prevY = mapValueInRange(value, 0, 1, 0, -100) 169 | nextY = mapValueInRange(value, 0, 1, 100, 0) 170 | break 171 | case Transition.type.PUSH_DOWN: 172 | prevY = mapValueInRange(value, 0, 1, 0, 100) 173 | nextY = mapValueInRange(value, 0, 1, -100, 0) 174 | break 175 | case Transition.type.COVER_LEFT: 176 | nextX = mapValueInRange(value, 0, 1, 100, 0) 177 | break 178 | case Transition.type.COVER_RIGHT: 179 | nextX = mapValueInRange(value, 0, 1, -100, 0) 180 | break 181 | case Transition.type.COVER_UP: 182 | nextY = mapValueInRange(value, 0, 1, 100, 0) 183 | break 184 | case Transition.type.COVER_DOWN: 185 | nextY = mapValueInRange(value, 0, 1, -100, 0) 186 | break 187 | case Transition.type.REVEAL_LEFT: 188 | prevX = mapValueInRange(value, 0, 1, 0, -100) 189 | break 190 | case Transition.type.REVEAL_RIGHT: 191 | prevX = mapValueInRange(value, 0, 1, 0, 100) 192 | break 193 | case Transition.type.REVEAL_UP: 194 | prevY = mapValueInRange(value, 0, 1, 0, -100) 195 | break 196 | case Transition.type.REVEAL_DOWN: 197 | prevY = mapValueInRange(value, 0, 1, 0, 100) 198 | break 199 | } 200 | return [prevX, prevY, nextX, nextY] 201 | } 202 | 203 | /** 204 | * Called once a view animation has completed 205 | */ 206 | __animateViewsComplete () { 207 | this.__isTransitioning = false 208 | const [prev, next] = this.__viewIndexes 209 | // Hide the previous view wrapper 210 | const prevViewWrapper = this.refs[`view-wrapper-${prev}`] 211 | prevViewWrapper.style.display = 'none' 212 | // Did hide view lifecycle event 213 | const prevView = this.refs['view-0'] 214 | if (prevView && typeof prevView.navigationControllerDidHideView === 'function') { 215 | prevView.navigationControllerDidHideView(this) 216 | } 217 | // Did show view lifecycle event 218 | const nextView = this.refs['view-1'] 219 | if (nextView && typeof nextView.navigationControllerDidShowView === 'function') { 220 | nextView.navigationControllerDidShowView(this) 221 | } 222 | // Unmount the previous view 223 | const mountedViews = [] 224 | mountedViews[prev] = null 225 | mountedViews[next] = last(this.state.views) 226 | 227 | this.setState({ 228 | transition: null, 229 | mountedViews: mountedViews 230 | }, () => { 231 | this.__viewIndexes = this.__viewIndexes[0] === 0 ? [1, 0] : [0, 1] 232 | }) 233 | } 234 | 235 | /** 236 | * Set the display style of the view wrappers 237 | * 238 | * @param {string} value 239 | */ 240 | __displayViews (value) { 241 | this.refs['view-wrapper-0'].style.display = value 242 | this.refs['view-wrapper-1'].style.display = value 243 | } 244 | 245 | /** 246 | * Transtion the view wrappers manually, using a built-in animation, or custom animation 247 | * 248 | * @param {string} transition 249 | * @param {function} [onComplete] - Called once the transition is complete 250 | */ 251 | __transitionViews (options) { 252 | options = typeof options === 'object' ? options : {} 253 | const defaults = { 254 | transitionTension: this.props.transitionTension, 255 | transitionFriction: this.props.transitionFriction 256 | } 257 | options = assign({}, defaults, options) 258 | const { 259 | transition, 260 | transitionTension, 261 | transitionFriction, 262 | onComplete 263 | } = options 264 | // Create a function that will be called once the 265 | this.__transitionViewsComplete = () => { 266 | delete this.__transitionViewsComplete 267 | if (typeof onComplete === 'function') { 268 | onComplete() 269 | } 270 | } 271 | // Will hide view lifecycle event 272 | const prevView = this.refs['view-0'] 273 | if (prevView && typeof prevView.navigationControllerWillHideView === 'function') { 274 | prevView.navigationControllerWillHideView(this) 275 | } 276 | // Will show view lifecycle event 277 | const nextView = this.refs['view-1'] 278 | if (nextView && typeof nextView.navigationControllerWillShowView === 'function') { 279 | nextView.navigationControllerWillShowView(this) 280 | } 281 | // Built-in transition 282 | if (typeof transition === 'number') { 283 | // Manually transition the views 284 | if (transition === Transition.type.NONE) { 285 | this.__transformViews.apply(this, 286 | this.__animateViews(1, transition) 287 | ) 288 | requestAnimationFrame(() => { 289 | this.__animateViewsComplete() 290 | this.__transitionViewsComplete() 291 | }) 292 | } else { 293 | // Otherwise use the springs 294 | this.__spring.setSpringConfig( 295 | new SpringConfig( 296 | OrigamiValueConverter.tensionFromOrigamiValue(transitionTension), 297 | OrigamiValueConverter.frictionFromOrigamiValue(transitionFriction) 298 | ) 299 | ) 300 | this.__spring.setEndValue(1) 301 | } 302 | } 303 | // Custom transition 304 | if (typeof transition === 'function') { 305 | const [prev, next] = this.__viewIndexes 306 | const prevView = this.refs[`view-wrapper-${prev}`] 307 | const nextView = this.refs[`view-wrapper-${next}`] 308 | transition(prevView, nextView, () => { 309 | this.__animateViewsComplete() 310 | this.__transitionViewsComplete() 311 | }) 312 | } 313 | } 314 | 315 | __onSpringUpdate (spring) { 316 | if (!this.__isTransitioning) return 317 | const value = spring.getCurrentValue() 318 | this.__transformViews.apply(this, 319 | this.__animateViews(value, this.state.transition) 320 | ) 321 | } 322 | 323 | __onSpringAtRest (spring) { 324 | this.__animateViewsComplete() 325 | this.__transitionViewsComplete() 326 | this.__spring.setCurrentValue(0) 327 | } 328 | 329 | /** 330 | * Push a new view onto the stack 331 | * 332 | * @param {ReactElement} view - The view to push onto the stack 333 | * @param {object} [options] 334 | * @param {function} options.onComplete - Called once the transition is complete 335 | * @param {number|function} [options.transition] - The transition type or custom transition 336 | */ 337 | __pushView (view, options) { 338 | options = typeof options === 'object' ? options : {} 339 | const defaults = { 340 | transition: Transition.type.PUSH_LEFT 341 | } 342 | options = assign({}, defaults, options, { view }) 343 | checkOptions('pushView', options) 344 | if (this.__isTransitioning) return 345 | const {transition} = options 346 | const [prev, next] = this.__viewIndexes 347 | let views = this.state.views.slice() 348 | // Alternate mounted views order 349 | const mountedViews = [] 350 | mountedViews[prev] = last(views) 351 | mountedViews[next] = view 352 | // Add the new view 353 | views = views.concat(view) 354 | // Show the wrappers 355 | this.__displayViews('block') 356 | // Push the view 357 | this.setState({ 358 | transition, 359 | views, 360 | mountedViews 361 | }, () => { 362 | // The view about to be hidden 363 | const prevView = this.refs[`view-0`] 364 | if (prevView && this.state.preserveState) { 365 | // Save the state before it gets unmounted 366 | this.__viewStates.push(prevView.state) 367 | } 368 | // Transition 369 | this.__transitionViews(options) 370 | }) 371 | this.__isTransitioning = true 372 | } 373 | 374 | /** 375 | * Pop the last view off the stack 376 | * 377 | * @param {object} [options] 378 | * @param {function} [options.onComplete] - Called once the transition is complete 379 | * @param {number|function} [options.transition] - The transition type or custom transition 380 | */ 381 | __popView (options) { 382 | options = typeof options === 'object' ? options : {} 383 | const defaults = { 384 | transition: Transition.type.PUSH_RIGHT 385 | } 386 | options = assign({}, defaults, options) 387 | checkOptions('popView', options) 388 | if (this.state.views.length === 1) { 389 | throw new Error('popView() can only be called with two or more views in the stack') 390 | } 391 | if (this.__isTransitioning) return 392 | const {transition} = options 393 | const [prev, next] = this.__viewIndexes 394 | const views = dropRight(this.state.views) 395 | // Alternate mounted views order 396 | const p = takeRight(this.state.views, 2).reverse() 397 | const mountedViews = [] 398 | mountedViews[prev] = p[0] 399 | mountedViews[next] = p[1] 400 | // Show the wrappers 401 | this.__displayViews('block') 402 | // Pop the view 403 | this.setState({ 404 | transition, 405 | views, 406 | mountedViews 407 | }, () => { 408 | // The view about to be shown 409 | const nextView = this.refs[`view-1`] 410 | if (nextView && this.state.preserveState) { 411 | const state = this.__viewStates.pop() 412 | // Rehydrate the state 413 | if (state) { 414 | nextView.setState(state) 415 | } 416 | } 417 | // Transition 418 | this.__transitionViews(options) 419 | }) 420 | this.__isTransitioning = true 421 | } 422 | 423 | /** 424 | * Replace the views currently managed by the controller 425 | * with the specified items. 426 | * 427 | * @param {array} views 428 | * @param {object} options 429 | * @param {function} [options.onComplete] - Called once the transition is complete 430 | * @param {number|function} [options.transition] - The transition type or custom transition 431 | * @param {boolean} [options.preserveState] - Wheter or not view states should be rehydrated 432 | */ 433 | __setViews (views, options) { 434 | options = typeof options === 'object' ? options : {} 435 | checkOptions('setViews', options) 436 | const {onComplete, preserveState} = options 437 | options = assign({}, options, { 438 | onComplete: () => { 439 | this.__viewStates.length = 0 440 | this.setState({ 441 | views, 442 | preserveState 443 | }, () => { 444 | if (onComplete) { 445 | onComplete() 446 | } 447 | }) 448 | } 449 | }) 450 | this.__pushView(last(views), options) 451 | } 452 | 453 | __popToRootView (options) { 454 | options = typeof options === 'object' ? options : {} 455 | const defaults = { 456 | transition: Transition.type.PUSH_RIGHT 457 | } 458 | options = assign({}, defaults, options) 459 | checkOptions('popToRootView', options) 460 | if (this.state.views.length === 1) { 461 | throw new Error('popToRootView() can only be called with two or more views in the stack') 462 | } 463 | if (this.__isTransitioning) return 464 | const {transition} = options 465 | const [prev, next] = this.__viewIndexes 466 | const rootView = this.state.views[0] 467 | const topView = last(this.state.views) 468 | const mountedViews = [] 469 | mountedViews[prev] = topView 470 | mountedViews[next] = rootView 471 | // Display only the root view 472 | const views = [rootView] 473 | // Show the wrappers 474 | this.__displayViews('block') 475 | // Pop from the top view, all the way to the root view 476 | this.setState({ 477 | transition, 478 | views, 479 | mountedViews 480 | }, () => { 481 | // The view that will be shown 482 | const rootView = this.refs[`view-1`] 483 | if (rootView && this.state.preserveState) { 484 | const state = this.__viewStates[0] 485 | // Rehydrate the state 486 | if (state) { 487 | rootView.setState(state) 488 | } 489 | } 490 | // Clear view states 491 | this.__viewStates.length = 0 492 | // Transition 493 | this.__transitionViews(options) 494 | }) 495 | this.__isTransitioning = true 496 | } 497 | 498 | pushView () { 499 | this.__pushView(...arguments) 500 | } 501 | 502 | popView () { 503 | this.__popView(...arguments) 504 | } 505 | 506 | popToRootView () { 507 | this.__popToRootView(...arguments) 508 | } 509 | 510 | setViews () { 511 | this.__setViews(...arguments) 512 | } 513 | 514 | __renderPrevView () { 515 | const view = this.state.mountedViews[0] 516 | if (!view) return null 517 | return React.cloneElement(view, { 518 | ref: `view-${this.__viewIndexes[0]}`, 519 | navigationController: this 520 | }) 521 | } 522 | 523 | __renderNextView () { 524 | const view = this.state.mountedViews[1] 525 | if (!view) return null 526 | return React.cloneElement(view, { 527 | ref: `view-${this.__viewIndexes[1]}`, 528 | navigationController: this 529 | }) 530 | } 531 | 532 | render () { 533 | const className = classNames('ReactNavigationController', 534 | this.props.className 535 | ) 536 | const wrapperClassName = classNames('ReactNavigationControllerView', { 537 | 'ReactNavigationControllerView--transitioning': this.__isTransitioning 538 | }) 539 | return ( 540 |
541 |
544 | {this.__renderPrevView()} 545 |
546 |
549 | {this.__renderNextView()} 550 |
551 |
552 | ) 553 | } 554 | 555 | } 556 | 557 | NavigationController.propTypes = { 558 | views: PropTypes.arrayOf( 559 | PropTypes.element 560 | ).isRequired, 561 | preserveState: PropTypes.bool, 562 | transitionTension: PropTypes.number, 563 | transitionFriction: PropTypes.number, 564 | className: PropTypes.oneOfType([ 565 | PropTypes.string, 566 | PropTypes.object 567 | ]) 568 | } 569 | 570 | NavigationController.defaultProps = { 571 | preserveState: false, 572 | transitionTension: 10, 573 | transitionFriction: 6 574 | } 575 | 576 | NavigationController.Transition = Transition 577 | 578 | export default NavigationController 579 | -------------------------------------------------------------------------------- /spec/navigation-controller.spec.jsx: -------------------------------------------------------------------------------- 1 | /* global describe, it, beforeEach, expect, requestAnimationFrame, sinon */ 2 | 3 | import React from 'react' 4 | 5 | import { 6 | isCompositeComponent, 7 | renderIntoDocument 8 | } from 'react-addons-test-utils' 9 | 10 | import rebound from 'rebound' 11 | 12 | import NavigationController from '../src/navigation-controller' 13 | import View from '../examples/src/view' 14 | 15 | const { Transition } = NavigationController 16 | 17 | class ViewA extends View { } 18 | class ViewB extends View { } 19 | class ViewC extends View { } 20 | 21 | describe('NavigationController', () => { 22 | const views = [ 23 | 24 | ] 25 | let controller, viewWrapper0, viewWrapper1 26 | beforeEach(() => { 27 | controller = renderIntoDocument( 28 | 29 | ) 30 | viewWrapper0 = controller.refs['view-wrapper-0'] 31 | viewWrapper1 = controller.refs['view-wrapper-1'] 32 | }) 33 | it('exports a component', () => { 34 | let controller = renderIntoDocument( 35 | 36 | ) 37 | expect(isCompositeComponent(controller)).to.be.true 38 | }) 39 | describe('#constructor', () => { 40 | it('correctly saves the views to the state', () => { 41 | const a =
42 | const b =
43 | controller = new NavigationController({ views: [a, b] }) 44 | expect(controller.state).not.to.be.undefined 45 | expect(controller.state.views).to.have.length(1) 46 | controller = new NavigationController({ views: [a] }) 47 | expect(controller.state).not.to.be.undefined 48 | expect(controller.state.views).to.have.length(0) 49 | }) 50 | }) 51 | describe('#componentWillMount', () => { 52 | beforeEach(() => { 53 | controller = new NavigationController({ views: views }) 54 | controller.componentWillMount() 55 | }) 56 | it('defaults to __isTransitioning=false ', () => { 57 | expect(controller.__isTransitioning).to.be.false 58 | }) 59 | it('sets up an array for the view states', () => { 60 | expect(controller.__viewStates).not.to.be.undefined 61 | expect(Array.isArray(controller.__viewStates)).to.be.true 62 | expect(controller.__viewStates).to.have.length(0) 63 | }) 64 | it('sets up an array for the view indexes', () => { 65 | expect(controller.__viewIndexes).not.to.be.undefined 66 | expect(Array.isArray(controller.__viewIndexes)).to.be.true 67 | expect(controller.__viewIndexes).to.have.length(2) 68 | expect(controller.__viewIndexes[0]).to.be.equal(0) 69 | expect(controller.__viewIndexes[1]).to.be.equal(1) 70 | }) 71 | it('creates a new spring system', () => { 72 | expect(controller.__springSystem).to.be.an.instanceof(rebound.SpringSystem) 73 | }) 74 | }) 75 | describe('#componentWillUnmount', () => { 76 | let spring 77 | beforeEach(() => { 78 | controller = new NavigationController({ views: views }) 79 | controller.componentWillMount() 80 | spring = controller.__spring 81 | controller.componentWillUnmount() 82 | }) 83 | it('cleans up spring system', () => { 84 | expect(controller.__springSystem).to.be.undefined 85 | }) 86 | it('cleans up spring', () => { 87 | expect(controller.__spring).to.be.undefined 88 | }) 89 | it('removes spring event listeners', () => { 90 | expect(spring.listeners).to.deep.equal([]) 91 | }) 92 | }) 93 | describe('#componentDidMount', () => { 94 | it('transforms the view wrappers', () => { 95 | expect(viewWrapper0).to.have.deep.property(`style.transform`) 96 | expect(viewWrapper1).to.have.deep.property(`style.transform`) 97 | }) 98 | }) 99 | describe('#__transformViews', () => { 100 | beforeEach(done => { 101 | requestAnimationFrame(() => { 102 | done() 103 | }) 104 | }) 105 | it('translates the views', (done) => { 106 | controller.__transformViews(10, 20, 30, 40) 107 | requestAnimationFrame(() => { 108 | expect(viewWrapper1.style.transform).to.equal(`translate(10%, 20%)`) 109 | expect(viewWrapper0.style.transform).to.equal(`translate(30%, 40%)`) 110 | done() 111 | }) 112 | }) 113 | it('sets the correct zIndex for Reveal transitions', (done) => { 114 | controller.setState({ 115 | transition: Transition.type.REVEAL_DOWN 116 | }, () => { 117 | controller.__transformViews(10, 20, 30, 40) 118 | requestAnimationFrame(() => { 119 | expect(viewWrapper0.style.zIndex).to.equal('0') 120 | expect(viewWrapper1.style.zIndex).to.equal('1') 121 | done() 122 | }) 123 | }) 124 | }) 125 | it('sets the correct zIndex for Push/Cover transitions', (done) => { 126 | controller.setState({ 127 | transition: Transition.type.COVER_DOWN 128 | }, () => { 129 | controller.__transformViews(10, 20, 30, 40) 130 | requestAnimationFrame(() => { 131 | expect(viewWrapper0.style.zIndex).to.equal('1') 132 | expect(viewWrapper1.style.zIndex).to.equal('0') 133 | done() 134 | }) 135 | }) 136 | }) 137 | }) 138 | describe('#__animateViews', () => { 139 | let prevX 140 | let prevY 141 | let nextX 142 | let nextY 143 | let set = (p) => { 144 | [prevX, prevY, nextX, nextY] = p 145 | } 146 | it('PUSH_LEFT', () => { 147 | set(controller.__animateViews(0, Transition.type.PUSH_LEFT)) 148 | expect(prevX).to.equal(0) 149 | expect(nextX).to.equal(100) 150 | set(controller.__animateViews(1, Transition.type.PUSH_LEFT)) 151 | expect(prevX).to.equal(-100) 152 | expect(nextX).to.equal(0) 153 | }) 154 | it('PUSH_RIGHT', () => { 155 | set(controller.__animateViews(0, Transition.type.PUSH_RIGHT)) 156 | expect(prevX).to.equal(0) 157 | expect(nextX).to.equal(-100) 158 | set(controller.__animateViews(1, Transition.type.PUSH_RIGHT)) 159 | expect(prevX).to.equal(100) 160 | expect(nextX).to.equal(0) 161 | }) 162 | it('PUSH_UP', () => { 163 | set(controller.__animateViews(0, Transition.type.PUSH_UP)) 164 | expect(prevY).to.equal(0) 165 | expect(nextY).to.equal(100) 166 | set(controller.__animateViews(1, Transition.type.PUSH_UP)) 167 | expect(prevY).to.equal(-100) 168 | expect(nextY).to.equal(0) 169 | }) 170 | it('PUSH_DOWN', () => { 171 | set(controller.__animateViews(0, Transition.type.PUSH_DOWN)) 172 | expect(prevY).to.equal(0) 173 | expect(nextY).to.equal(-100) 174 | set(controller.__animateViews(1, Transition.type.PUSH_DOWN)) 175 | expect(prevY).to.equal(100) 176 | expect(nextY).to.equal(0) 177 | }) 178 | it('COVER_LEFT', () => { 179 | set(controller.__animateViews(0, Transition.type.COVER_LEFT)) 180 | expect(prevX).to.equal(0) 181 | expect(nextX).to.equal(100) 182 | set(controller.__animateViews(1, Transition.type.COVER_LEFT)) 183 | expect(prevX).to.equal(0) 184 | expect(nextX).to.equal(0) 185 | }) 186 | it('COVER_RIGHT', () => { 187 | set(controller.__animateViews(0, Transition.type.COVER_RIGHT)) 188 | expect(prevX).to.equal(0) 189 | expect(nextX).to.equal(-100) 190 | set(controller.__animateViews(1, Transition.type.COVER_RIGHT)) 191 | expect(prevX).to.equal(0) 192 | expect(nextX).to.equal(0) 193 | }) 194 | it('COVER_UP', () => { 195 | set(controller.__animateViews(0, Transition.type.COVER_UP)) 196 | expect(prevY).to.equal(0) 197 | expect(nextY).to.equal(100) 198 | set(controller.__animateViews(1, Transition.type.COVER_UP)) 199 | expect(prevY).to.equal(0) 200 | expect(nextY).to.equal(0) 201 | }) 202 | it('COVER_DOWN', () => { 203 | set(controller.__animateViews(0, Transition.type.COVER_DOWN)) 204 | expect(prevY).to.equal(0) 205 | expect(nextY).to.equal(-100) 206 | set(controller.__animateViews(1, Transition.type.COVER_DOWN)) 207 | expect(prevY).to.equal(0) 208 | expect(nextY).to.equal(0) 209 | }) 210 | it('REVEAL_LEFT', () => { 211 | set(controller.__animateViews(0, Transition.type.REVEAL_LEFT)) 212 | expect(prevX).to.equal(0) 213 | expect(nextX).to.equal(0) 214 | set(controller.__animateViews(1, Transition.type.REVEAL_LEFT)) 215 | expect(prevX).to.equal(-100) 216 | expect(nextX).to.equal(0) 217 | }) 218 | it('REVEAL_RIGHT', () => { 219 | set(controller.__animateViews(0, Transition.type.REVEAL_RIGHT)) 220 | expect(prevX).to.equal(0) 221 | expect(nextX).to.equal(0) 222 | set(controller.__animateViews(1, Transition.type.REVEAL_RIGHT)) 223 | expect(prevX).to.equal(100) 224 | expect(nextX).to.equal(0) 225 | }) 226 | it('REVEAL_UP', () => { 227 | set(controller.__animateViews(0, Transition.type.REVEAL_UP)) 228 | expect(prevY).to.equal(0) 229 | expect(nextY).to.equal(0) 230 | set(controller.__animateViews(1, Transition.type.REVEAL_UP)) 231 | expect(prevY).to.equal(-100) 232 | expect(nextY).to.equal(0) 233 | }) 234 | it('REVEAL_DOWN', () => { 235 | set(controller.__animateViews(0, Transition.type.REVEAL_DOWN)) 236 | expect(prevY).to.equal(0) 237 | expect(nextY).to.equal(0) 238 | set(controller.__animateViews(1, Transition.type.REVEAL_DOWN)) 239 | expect(prevY).to.equal(100) 240 | expect(nextY).to.equal(0) 241 | }) 242 | }) 243 | describe('#__animateViewsComplete', () => { 244 | it('sets to __isTransitioning=false ', () => { 245 | controller.__animateViewsComplete() 246 | expect(controller.__isTransitioning).to.be.false 247 | }) 248 | it('hides the previous view wrapper ', (done) => { 249 | controller.__animateViewsComplete() 250 | const [prev] = controller.__viewIndexes 251 | requestAnimationFrame(() => { 252 | expect(controller.refs[`view-wrapper-${prev}`].style.display).to.equal('none') 253 | done() 254 | }) 255 | }) 256 | it('unmounts the previous view', (done) => { 257 | let prev 258 | let next 259 | requestAnimationFrame(() => { 260 | [prev, next] = controller.__viewIndexes.slice() 261 | controller.__animateViewsComplete() 262 | }) 263 | requestAnimationFrame(() => { 264 | expect(controller.state.mountedViews[prev]).to.be.null 265 | expect(controller.state.mountedViews[next].type).to.equal(ViewA) 266 | done() 267 | }) 268 | }) 269 | it('alternates the view indexes', (done) => { 270 | let a 271 | let b 272 | requestAnimationFrame(() => { 273 | a = controller.__viewIndexes.slice() 274 | controller.__animateViewsComplete() 275 | }) 276 | requestAnimationFrame(() => { 277 | b = controller.__viewIndexes.slice() 278 | expect(a[0]).to.equal(b[1]) 279 | expect(a[1]).to.equal(b[0]) 280 | done() 281 | }) 282 | }) 283 | }) 284 | describe('#__displayViews', () => { 285 | beforeEach(done => { 286 | requestAnimationFrame(() => { 287 | done() 288 | }) 289 | }) 290 | it('hides the views', (done) => { 291 | controller.__displayViews('none') 292 | requestAnimationFrame(() => { 293 | expect(controller.refs[`view-wrapper-0`].style.display).to.equal('none') 294 | expect(controller.refs[`view-wrapper-1`].style.display).to.equal('none') 295 | done() 296 | }) 297 | }) 298 | it('shows the views', (done) => { 299 | controller.__displayViews('block') 300 | requestAnimationFrame(() => { 301 | expect(controller.refs[`view-wrapper-0`].style.display).to.equal('block') 302 | expect(controller.refs[`view-wrapper-1`].style.display).to.equal('block') 303 | done() 304 | }) 305 | }) 306 | }) 307 | describe('#__transitionViews', () => { 308 | beforeEach(done => { 309 | requestAnimationFrame(() => { 310 | done() 311 | }) 312 | }) 313 | it('sets the completion callback', () => { 314 | controller.__transitionViews({}) 315 | expect(controller.__transitionViewsComplete).to.be.a('function') 316 | }) 317 | it('sets and calls the completion callback', (done) => { 318 | const transitionCallbackSpy = sinon.spy() 319 | controller.__transitionViews({ 320 | transition: Transition.type.NONE, 321 | onComplete: transitionCallbackSpy 322 | }) 323 | const transitionCompleteSpy = sinon.spy(controller, '__transitionViewsComplete') 324 | requestAnimationFrame(() => { 325 | expect(transitionCompleteSpy.calledOnce).to.be.true 326 | expect(transitionCallbackSpy.calledOnce).to.be.true 327 | done() 328 | }) 329 | }) 330 | it('manually runs a "none" transition', (done) => { 331 | const transformSpy = sinon.spy(controller, '__transformViews') 332 | const animateCompleteSpy = sinon.spy(controller, '__animateViewsComplete') 333 | controller.__transitionViews({ 334 | transition: Transition.type.NONE 335 | }) 336 | const transitionCompleteSpy = sinon.spy(controller, '__transitionViewsComplete') 337 | requestAnimationFrame(() => { 338 | expect(transformSpy.calledOnce).to.be.true 339 | expect(animateCompleteSpy.calledOnce).to.be.true 340 | expect(transitionCompleteSpy.calledOnce).to.be.true 341 | done() 342 | }) 343 | }) 344 | it('runs a built-in spring transition', (done) => { 345 | const animateSpy = sinon.spy(controller, '__animateViews') 346 | const transformSpy = sinon.spy(controller, '__transformViews') 347 | const animateCompleteSpy = sinon.spy(controller, '__animateViewsComplete') 348 | controller.__transitionViews({ 349 | transition: Transition.type.PUSH_LEFT, 350 | onComplete () { 351 | expect(animateSpy.callCount).to.be.above(1) 352 | expect(transformSpy.callCount).to.be.above(1) 353 | expect(animateCompleteSpy.calledOnce).to.be.true 354 | done() 355 | } 356 | }) 357 | controller.__isTransitioning = true 358 | }) 359 | it('runs a custom transtion', (done) => { 360 | let _prevElement 361 | let _nextElement 362 | controller.__transitionViews({ 363 | transition (prevElement, nextElement, done) { 364 | _prevElement = prevElement 365 | _nextElement = nextElement 366 | prevElement.style.transform = 'translate(10px, 20px)' 367 | nextElement.style.transform = 'translate(30px, 40px)' 368 | setTimeout(done, 500) 369 | }, 370 | onComplete () { 371 | expect(_prevElement.style.transform).to.equal(`translate(10px, 20px)`) 372 | expect(_nextElement.style.transform).to.equal(`translate(30px, 40px)`) 373 | done() 374 | } 375 | }) 376 | }) 377 | }) 378 | describe('#__pushView', () => { 379 | beforeEach(done => { 380 | requestAnimationFrame(() => { 381 | done() 382 | }) 383 | }) 384 | it('throws an error if a non-react view is passed', () => { 385 | expect(() => { 386 | controller.__pushView({}) 387 | }).to.throw(/view/) 388 | }) 389 | it('throws an error if an invalid callback is passed', () => { 390 | expect(() => { 391 | controller.__pushView(, { onComplete: true }) 392 | }).to.throw(/onComplete/) 393 | }) 394 | it('throws an error if an invalid transition is passed', () => { 395 | expect(() => { 396 | controller.__pushView(, { transition: true }) 397 | }).to.throw(/transition/) 398 | }) 399 | it('returns early if the controller is already transitioning', () => { 400 | const spy = sinon.spy(controller, 'setState') 401 | controller.__isTransitioning = true 402 | controller.__pushView() 403 | expect(spy.called).not.to.be.true 404 | }) 405 | it('shows the view wrappers', () => { 406 | const spy = sinon.spy(controller, '__displayViews') 407 | controller.__pushView() 408 | expect(spy.calledWith('block')).to.be.true 409 | }) 410 | it('appends the view to state.views', (done) => { 411 | controller.__pushView(, { 412 | transition: Transition.type.NONE, 413 | onComplete () { 414 | expect(controller.state.views[1].type).to.equal(ViewB) 415 | done() 416 | } 417 | }) 418 | }) 419 | it('sets state.transition', (done) => { 420 | controller.__pushView(, { 421 | transition: Transition.type.NONE, 422 | onComplete () { 423 | done() 424 | } 425 | }) 426 | requestAnimationFrame(() => { 427 | expect(controller.state.transition).to.equal(Transition.type.NONE) 428 | }) 429 | }) 430 | it('sets state.mountedViews', (done) => { 431 | const [prev, next] = controller.__viewIndexes 432 | controller.__pushView(, { 433 | transition: Transition.type.PUSH_LEFT, 434 | onComplete () { 435 | expect(controller.state.views[1].type).to.equal(ViewB) 436 | done() 437 | } 438 | }) 439 | requestAnimationFrame(() => { 440 | expect(controller.state.mountedViews[prev].type).to.equal(ViewA) 441 | expect(controller.state.mountedViews[next].type).to.equal(ViewB) 442 | }) 443 | }) 444 | it('transitions the views', (done) => { 445 | const spy = sinon.spy(controller, '__transitionViews') 446 | controller.__pushView(, { transition: Transition.type.NONE }) 447 | requestAnimationFrame(() => { 448 | expect(spy.calledOnce).to.be.true 449 | done() 450 | }) 451 | }) 452 | it('sets __isTransitioning=true', () => { 453 | controller.__pushView(, { transition: Transition.type.NONE }) 454 | expect(controller.__isTransitioning).to.be.true 455 | }) 456 | it('calls the transitionDone callback', (done) => { 457 | controller.__pushView(, { 458 | transition: Transition.type.NONE, 459 | onComplete () { 460 | expect(true).to.be.true 461 | done() 462 | } 463 | }) 464 | }) 465 | it('preserves the state', (done) => { 466 | controller = renderIntoDocument( 467 | 468 | ) 469 | requestAnimationFrame(() => { 470 | controller.refs[`view-${controller.__viewIndexes[0]}`].setState({ 471 | foo: 'bar' 472 | }) 473 | controller.__pushView(, { transition: Transition.type.NONE }) 474 | requestAnimationFrame(() => { 475 | expect(controller.__viewStates).to.have.length(1) 476 | expect(controller.__viewStates[0]).to.have.property('foo') 477 | done() 478 | }) 479 | }) 480 | }) 481 | }) 482 | describe('#__popView', () => { 483 | beforeEach(done => { 484 | controller = renderIntoDocument( 485 | , ]} /> 486 | ) 487 | requestAnimationFrame(() => { 488 | done() 489 | }) 490 | }) 491 | it('throws an error if an only one view is in the stack', () => { 492 | controller.state.views = [] 493 | expect(() => { 494 | controller.__popView() 495 | }).to.throw(/stack/) 496 | }) 497 | it('throws an error if an invalid callback is passed', () => { 498 | expect(() => { 499 | controller.__popView({ onComplete: true }) 500 | }).to.throw(/onComplete/) 501 | }) 502 | it('throws an error if an invalid transition is passed', () => { 503 | expect(() => { 504 | controller.__popView({ transition: true }) 505 | }).to.throw(/transition/) 506 | }) 507 | it('returns early if the controller is already transitioning', () => { 508 | const spy = sinon.spy(controller, 'setState') 509 | controller.__isTransitioning = true 510 | controller.__popView() 511 | expect(spy.called).not.to.be.true 512 | }) 513 | it('shows the view wrappers', () => { 514 | const spy = sinon.spy(controller, '__displayViews') 515 | controller.__popView() 516 | expect(spy.calledWith('block')).to.be.true 517 | }) 518 | it('removes the last view from state.views', (done) => { 519 | controller.__popView({ 520 | onComplete () { 521 | expect(controller.state.views).to.have.length(1) 522 | expect(controller.state.views[0].type).to.equal(ViewA) 523 | done() 524 | }, 525 | transition: Transition.type.NONE 526 | }) 527 | }) 528 | it('sets state.transition', (done) => { 529 | controller.__popView({ 530 | transition: Transition.type.NONE, 531 | onComplete () { 532 | done() 533 | } 534 | }) 535 | requestAnimationFrame(() => { 536 | expect(controller.state.transition).to.equal(Transition.type.NONE) 537 | }) 538 | }) 539 | it('sets state.mountedViews', (done) => { 540 | const [prev, next] = controller.__viewIndexes 541 | controller.__popView({ 542 | transition: Transition.type.PUSH_RIGHT, 543 | onComplete () { 544 | done() 545 | } 546 | }) 547 | requestAnimationFrame(() => { 548 | expect(controller.state.mountedViews[prev].type).to.equal(ViewB) 549 | expect(controller.state.mountedViews[next].type).to.equal(ViewA) 550 | }) 551 | }) 552 | it('transitions the views', (done) => { 553 | const spy = sinon.spy(controller, '__transitionViews') 554 | controller.__popView({ transition: Transition.type.NONE }) 555 | requestAnimationFrame(() => { 556 | expect(spy.calledOnce).to.be.true 557 | done() 558 | }) 559 | }) 560 | it('sets __isTransitioning=true', () => { 561 | controller.__popView({ transition: Transition.type.NONE }) 562 | expect(controller.__isTransitioning).to.be.true 563 | }) 564 | it('calls the onComplete callback', (done) => { 565 | controller.__popView({ 566 | onComplete () { 567 | expect(true).to.be.true 568 | done() 569 | } 570 | }) 571 | }) 572 | it('does not rehydrate the state', (done) => { 573 | requestAnimationFrame(() => { 574 | controller.refs[`view-${controller.__viewIndexes[0]}`].setState({ 575 | foo: 'bar' 576 | }) 577 | controller.pushView(, { 578 | transition: Transition.type.NONE, 579 | onComplete () { 580 | controller.popView({ 581 | transition: Transition.type.NONE, 582 | onComplete () { 583 | expect(controller.refs[`view-${controller.__viewIndexes[0]}`].state) 584 | .not.to.have.property('foo') 585 | done() 586 | } 587 | }) 588 | } 589 | }) 590 | }) 591 | }) 592 | it('rehydrates the state', (done) => { 593 | controller = renderIntoDocument( 594 | 595 | ) 596 | requestAnimationFrame(() => { 597 | controller.refs[`view-${controller.__viewIndexes[0]}`].setState({ 598 | foo: 'bar' 599 | }) 600 | controller.pushView(, { 601 | transition: Transition.type.NONE, 602 | onComplete () { 603 | controller.popView({ 604 | transition: Transition.type.NONE, 605 | onComplete () { 606 | expect(controller.refs[`view-${controller.__viewIndexes[0]}`].state) 607 | .to.have.property('foo') 608 | done() 609 | } 610 | }) 611 | } 612 | }) 613 | }) 614 | }) 615 | }) 616 | describe('#__popToRootView', () => { 617 | beforeEach(done => { 618 | controller = renderIntoDocument( 619 | , , ]} /> 620 | ) 621 | requestAnimationFrame(() => { 622 | done() 623 | }) 624 | }) 625 | it('throws an error if an only one view is in the stack', () => { 626 | controller.state.views = [] 627 | expect(() => { 628 | controller.__popToRootView() 629 | }).to.throw(/stack/) 630 | }) 631 | it('returns early if the controller is already transitioning', () => { 632 | const spy = sinon.spy(controller, 'setState') 633 | controller.__isTransitioning = true 634 | controller.__popToRootView() 635 | expect(spy.called).not.to.be.true 636 | }) 637 | it('shows the view wrappers', () => { 638 | const spy = sinon.spy(controller, '__displayViews') 639 | controller.__popToRootView() 640 | expect(spy.calledWith('block')).to.be.true 641 | }) 642 | it('removes all but the root view from state.views', (done) => { 643 | controller.__popToRootView({ 644 | onComplete () { 645 | expect(controller.state.views).to.have.length(1) 646 | expect(controller.state.views[0].type).to.equal(ViewA) 647 | done() 648 | }, 649 | transition: Transition.type.NONE 650 | }) 651 | }) 652 | it('sets state.transition', (done) => { 653 | controller.__popToRootView({ 654 | transition: Transition.type.NONE, 655 | onComplete () { 656 | done() 657 | } 658 | }) 659 | requestAnimationFrame(() => { 660 | expect(controller.state.transition).to.equal(Transition.type.NONE) 661 | }) 662 | }) 663 | it('sets state.mountedViews', (done) => { 664 | const [prev, next] = controller.__viewIndexes 665 | controller.__popToRootView({ 666 | transition: Transition.type.PUSH_RIGHT, 667 | onComplete () { 668 | done() 669 | } 670 | }) 671 | requestAnimationFrame(() => { 672 | expect(controller.state.mountedViews[prev].type).to.equal(ViewC) 673 | expect(controller.state.mountedViews[next].type).to.equal(ViewA) 674 | }) 675 | }) 676 | it('transitions the views', (done) => { 677 | const spy = sinon.spy(controller, '__transitionViews') 678 | controller.__popToRootView({ transition: Transition.type.NONE }) 679 | requestAnimationFrame(() => { 680 | expect(spy.calledOnce).to.be.true 681 | done() 682 | }) 683 | }) 684 | it('sets __isTransitioning=true', () => { 685 | controller.__popToRootView({ transition: Transition.type.NONE }) 686 | expect(controller.__isTransitioning).to.be.true 687 | }) 688 | it('calls the onComplete callback', (done) => { 689 | controller.__popToRootView({ 690 | onComplete () { 691 | expect(true).to.be.true 692 | done() 693 | } 694 | }) 695 | }) 696 | it('does not rehydrate the state', (done) => { 697 | controller = renderIntoDocument( 698 | ]} preserveState={false} /> 699 | ) 700 | requestAnimationFrame(() => { 701 | var rootView = controller.refs[`view-${controller.__viewIndexes[0]}`] 702 | rootView.setState({ 703 | foo: 'bar' 704 | }) 705 | controller.pushView(, { 706 | transition: Transition.type.NONE, 707 | onComplete () { 708 | controller.pushView(, { 709 | transition: Transition.type.NONE, 710 | onComplete () { 711 | controller.popToRootView({ 712 | transition: Transition.type.NONE, 713 | onComplete () { 714 | rootView = controller.refs[`view-${controller.__viewIndexes[1]}`] 715 | expect(rootView.state) 716 | .not.to.have.property('foo') 717 | done() 718 | } 719 | }) 720 | } 721 | }) 722 | } 723 | }) 724 | }) 725 | }) 726 | it('rehydrates the state', (done) => { 727 | controller = renderIntoDocument( 728 | ]} preserveState /> 729 | ) 730 | requestAnimationFrame(() => { 731 | controller.refs[`view-${controller.__viewIndexes[0]}`].setState({ 732 | foo: 'bar' 733 | }) 734 | controller.pushView(, { 735 | transition: Transition.type.NONE, 736 | onComplete () { 737 | controller.pushView(, { 738 | transition: Transition.type.NONE, 739 | onComplete () { 740 | controller.popToRootView({ 741 | transition: Transition.type.NONE, 742 | onComplete () { 743 | expect(controller.refs[`view-${controller.__viewIndexes[1]}`].state) 744 | .to.have.property('foo') 745 | done() 746 | } 747 | }) 748 | } 749 | }) 750 | } 751 | }) 752 | }) 753 | }) 754 | }) 755 | describe('#__setViews', () => { 756 | beforeEach(done => { 757 | requestAnimationFrame(() => { 758 | done() 759 | }) 760 | }) 761 | it('pushes the last view on the stack', () => { 762 | controller.__setViews([], { 763 | transition: Transition.type.NONE, 764 | onComplete () { 765 | expect(controller.state.views).to.have.length(1) 766 | } 767 | }) 768 | }) 769 | it('clears the saved view states', () => { 770 | controller.__setViews([], { 771 | transition: Transition.type.NONE, 772 | onComplete () { 773 | expect(controller.__viewStates).to.have.length(0) 774 | } 775 | }) 776 | }) 777 | }) 778 | describe('#__renderPrevView', () => { 779 | beforeEach(done => { 780 | requestAnimationFrame(() => { 781 | done() 782 | }) 783 | }) 784 | it('returns null if the previous view is no longer mounted', () => { 785 | expect(controller.__renderPrevView()).to.be.null 786 | }) 787 | it('returns a clone if the previous view is mounted', (done) => { 788 | controller.__pushView() 789 | requestAnimationFrame(() => { 790 | const prevView = controller.__renderPrevView() 791 | const ref = controller.refs[`view-${controller.__viewIndexes[0]}`] 792 | expect(prevView).not.to.be.null 793 | expect(ref).not.to.be.null 794 | expect(ref.props.navigationController).to.equal(controller) 795 | done() 796 | }) 797 | }) 798 | }) 799 | describe('#__renderNextView', () => { 800 | beforeEach(done => { 801 | requestAnimationFrame(() => { 802 | done() 803 | }) 804 | }) 805 | it('returns null if the next view is no longer mounted', (done) => { 806 | controller.__pushView(, { 807 | transition: Transition.type.NONE, 808 | onComplete () { 809 | expect(controller.__renderNextView()).to.be.null 810 | done() 811 | } 812 | }) 813 | }) 814 | it('returns a clone if the next view is mounted', (done) => { 815 | controller.__pushView() 816 | requestAnimationFrame(() => { 817 | const nextView = controller.__renderNextView() 818 | const ref = controller.refs[`view-${controller.__viewIndexes[1]}`] 819 | expect(nextView).not.to.be.null 820 | expect(ref).not.to.be.null 821 | expect(ref.props.navigationController).to.equal(controller) 822 | done() 823 | }) 824 | }) 825 | }) 826 | describe('Lifecycle Events', () => { 827 | let stubLifecycleEvents = (onTransitionViews) => { 828 | const e = { 829 | prevView: { 830 | willHide: sinon.spy(), 831 | didHide: sinon.spy() 832 | }, 833 | nextView: { 834 | willShow: sinon.spy(), 835 | didShow: sinon.spy() 836 | } 837 | } 838 | const stub = sinon.stub(controller, '__transitionViews', (options) => { 839 | let prevView = controller.refs['view-0'] 840 | if (prevView) { 841 | prevView.navigationControllerWillHideView = e.prevView.willHide 842 | prevView.navigationControllerDidHideView = e.prevView.didHide 843 | } 844 | let nextView = controller.refs['view-1'] 845 | if (nextView) { 846 | nextView.navigationControllerWillShowView = e.nextView.willShow 847 | nextView.navigationControllerDidShowView = e.nextView.didShow 848 | } 849 | stub.restore() 850 | controller.__transitionViews(options) 851 | onTransitionViews() 852 | }) 853 | return e 854 | } 855 | let expectCallsBeforeTransition = (e) => { 856 | expect(e.prevView.willHide.calledOnce).to.be.true 857 | expect(e.nextView.willShow.calledOnce).to.be.true 858 | expect(e.prevView.didHide.calledOnce).to.be.false 859 | expect(e.nextView.didShow.calledOnce).to.be.false 860 | expect(e.prevView.willHide.calledBefore(e.nextView.willShow)).to.be.true 861 | } 862 | let expectCallsAfterTransition = (e) => { 863 | expect(e.prevView.didHide.calledOnce).to.be.true 864 | expect(e.nextView.didShow.calledOnce).to.be.true 865 | expect(e.prevView.didHide.calledBefore(e.nextView.didShow)).to.be.true 866 | } 867 | describe('#__pushView', () => { 868 | beforeEach(done => { 869 | requestAnimationFrame(() => { 870 | done() 871 | }) 872 | }) 873 | it('calls events with a "none" transition', (done) => { 874 | const e = stubLifecycleEvents(() => { 875 | expectCallsBeforeTransition(e) 876 | }) 877 | controller.__pushView(, { 878 | transition: Transition.type.NONE, 879 | onComplete () { 880 | expectCallsAfterTransition(e) 881 | done() 882 | } 883 | }) 884 | }) 885 | it('calls events with a built-in spring animation', (done) => { 886 | const e = stubLifecycleEvents(() => { 887 | expectCallsBeforeTransition(e) 888 | }) 889 | controller.__pushView(, { 890 | transition: Transition.type.PUSH_LEFT, 891 | onComplete () { 892 | expectCallsAfterTransition(e) 893 | done() 894 | } 895 | }) 896 | }) 897 | }) 898 | describe('#__popView', () => { 899 | beforeEach(done => { 900 | controller = renderIntoDocument( 901 | , ]} /> 902 | ) 903 | requestAnimationFrame(() => { 904 | done() 905 | }) 906 | }) 907 | it('calls events with a "none" transition', (done) => { 908 | const e = stubLifecycleEvents(() => { 909 | expectCallsBeforeTransition(e) 910 | }) 911 | controller.__popView({ 912 | transition: Transition.type.NONE, 913 | onComplete () { 914 | expectCallsAfterTransition(e) 915 | done() 916 | } 917 | }) 918 | }) 919 | it('calls events with a built-in spring animation', (done) => { 920 | const e = stubLifecycleEvents(() => { 921 | expectCallsBeforeTransition(e) 922 | }) 923 | controller.__popView({ 924 | transition: Transition.type.PUSH_LEFT, 925 | onComplete () { 926 | expectCallsAfterTransition(e) 927 | done() 928 | } 929 | }) 930 | }) 931 | }) 932 | describe('#__popToRootView', () => { 933 | beforeEach(done => { 934 | controller = renderIntoDocument( 935 | , , ]} /> 936 | ) 937 | requestAnimationFrame(() => { 938 | done() 939 | }) 940 | }) 941 | it('calls events with a "none" transition', (done) => { 942 | const e = stubLifecycleEvents(() => { 943 | expectCallsBeforeTransition(e) 944 | }) 945 | controller.__popToRootView({ 946 | transition: Transition.type.NONE, 947 | onComplete () { 948 | expectCallsAfterTransition(e) 949 | done() 950 | } 951 | }) 952 | }) 953 | it('calls events with a built-in spring animation', (done) => { 954 | const e = stubLifecycleEvents(() => { 955 | expectCallsBeforeTransition(e) 956 | }) 957 | controller.__popToRootView({ 958 | transition: Transition.type.PUSH_LEFT, 959 | onComplete () { 960 | expectCallsAfterTransition(e) 961 | done() 962 | } 963 | }) 964 | }) 965 | }) 966 | }) 967 | }) 968 | --------------------------------------------------------------------------------