├── .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 |
66 | Increment Counter ({this.state.counter})
67 |
68 |
69 | Show Modal
70 |
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 | : {text}
81 | }
82 | renderNextButton () {
83 | return this.props.modal === true
84 | ?
85 | : Next
86 | }
87 | renderPopToRootButton () {
88 | return this.props.index === 1
89 | ?
90 | : Pop To Root
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 |
34 | Login
35 |
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 |
--------------------------------------------------------------------------------