├── .gitignore ├── LICENSE ├── README.md ├── index.js ├── package.json └── src ├── navigator.js ├── route.js ├── router.js ├── stack.js ├── transition.js └── util-state.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Ryan Leckey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # (React Native) Razor 2 | > Router for React Native with declarative configuration similar to React Router. 3 | 4 | - Declarative configuration (using `` similar to react-router) 5 | - Idiomatic React; no imperative API or self-contained state (unlike react-router) 6 | - Uses `NavigatorExperimental` (soon to replace `Navigator` and `NavigatorIOS` in core React Native) 7 | 8 | ## Install 9 | 10 | ``` 11 | npm install rn-razor --save 12 | ``` 13 | 14 | ## Usage 15 | 16 | ##### Configuration 17 | 18 | ```javascript 19 | import {Router, Route, StateUtils} from "rn-razor"; 20 | 21 | class Application extends React.Component { 22 | state = { 23 | routerState: StateUtils.create({initialRoute: "index"}), 24 | }; 25 | 26 | render() { 27 | 28 | 29 | 30 | 31 | 32 | 33 | } 34 | } 35 | ``` 36 | 37 | ##### Navigation 38 | 39 | ```javascript 40 | import {StateUtils} from "rn-razor"; 41 | 42 | routerState = StateUtils.push(routerState, "profile"); 43 | routerState = StateUtils.pop(routerState); 44 | routerState = StateUtils.jumpTo(routerState, "contact", {id: 10}); 45 | routerState = StateUtils.resetTo(routerState, "contact", {id: 10}); 46 | ``` 47 | 48 | ## Reference 49 | 50 | ### `` 51 | 52 | ##### `routerState` 53 | 54 | The current state of navigation. 55 | 56 | Can be initialized to an initial route with `StateUtils.create({initialRoute: "..."})`. 57 | 58 | ###### Example 59 | 60 | ``` 61 | { 62 | index: 1, 63 | routes: [ 64 | { name: "index" }, 65 | { name: "contact-detail", params: { id: 3 } }, 66 | ] 67 | } 68 | ``` 69 | 70 | ##### `children` 71 | 72 | A collection of `` components providing the declarative configuration. 73 | 74 | ##### `onWillFocus` 75 | 76 | Called when a route is about to be rendered or "focused" (name comes from react-native). This is called after the route's `onEnter` (if present). 77 | 78 | ##### `onDidFocus` 79 | 80 | Called when a route has been rendered or "focused". This is called after the route component's `componentDidMount` (tip: good place to hide a splash screen from [rn-splash-screen](https://github.com/mehcode/rn-splash-screen)). 81 | 82 | ##### `createElement` 83 | 84 | When the router is ready to render a specific scene, it will use this function to create the elements. 85 | 86 | ###### Default 87 | 88 | ```js 89 | function createElement(Component, props) { 90 | return 91 | } 92 | ``` 93 | 94 | ##### `render` 95 | 96 | When the router is ready to render the scene stack, it will use this function. 97 | 98 | Use this callback to add persistent views around the scene stack. Perhaps a navigation drawer or wrap `scenes` in `KeyboardAvoidingView` from react-native. 99 | 100 | ###### Default 101 | 102 | ```js 103 | function render(scenes) { 104 | return scenes; 105 | } 106 | ``` 107 | 108 | ### `` 109 | 110 | ##### `name` 111 | 112 | The route key to use as a unique index during navigation. 113 | 114 | ##### `component` 115 | 116 | The component to be rendered for the route. 117 | 118 | ##### `onEnter` 119 | 120 | Called when a route is about to be entered. 121 | 122 | ##### `onLeave` 123 | 124 | Called when a route is about to be exited. Called before the next route's `onEnter`. 125 | 126 | ##### `children` 127 | 128 | A collection of `` components that are treated as a group, invoking their parent's `onEnter` and `onLeave` as a group. 129 | 130 | For instance, a group of routes can be given a single `onLeave` or `onEnter` that is called when the router is no longer in the group or when entering the group for the first time. 131 | 132 | Note that a `` cannot have both a `component` and `children` as component nesting is not currently supported. 133 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | Router: require("./src/router").default, 3 | Route: require("./src/route").default, 4 | StateUtils: require("./src/util-state"), 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rn-razor", 3 | "version": "0.4.1", 4 | "description": "Router for React Native with declarative configuration similar to React Router.", 5 | "main": "index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/mehcode/rn-razor.git" 9 | }, 10 | "keywords": [ 11 | "react-native", 12 | "react", 13 | "react-router", 14 | "router", 15 | "react-component" 16 | ], 17 | "author": "Ryan Leckey ", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/mehcode/rn-razor/issues" 21 | }, 22 | "homepage": "https://github.com/mehcode/rn-razor#readme", 23 | "dependencies": { 24 | "invariant": "^2.2.1", 25 | "lodash": "^4.13.1" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/navigator.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import {isEqual} from "lodash"; 3 | import React, {PropTypes, Component} from "react"; 4 | import {NavigationExperimental, View} from "react-native"; 5 | import invariant from "invariant"; 6 | 7 | import Stack from "./stack"; 8 | 9 | const { 10 | StateUtils: NavigationStateUtils, 11 | } = NavigationExperimental; 12 | 13 | export default class Navigator extends React.PureComponent { 14 | static propTypes = { 15 | style: View.propTypes.style, 16 | render: PropTypes.func, 17 | navigationState: PropTypes.object.isRequired, 18 | createElement: PropTypes.func, 19 | onDidFocus: PropTypes.func, 20 | onWillFocus: PropTypes.func, 21 | }; 22 | 23 | constructor(props, context) { 24 | super(props, context); 25 | 26 | this._renderScene = this._renderScene.bind(this); 27 | 28 | this._routeComponents = {}; 29 | 30 | // Build map of name => id 31 | // NOTE: routes cannot change after construction 32 | this._routesByName = {}; 33 | for (const routeId of Object.keys(props.routes)) { 34 | const route = props.routes[routeId]; 35 | if (route.name) { 36 | this._routesByName[route.name] = route.id; 37 | } 38 | } 39 | } 40 | 41 | componentWillMount() { 42 | this._handleWillFocus(this.props); 43 | } 44 | 45 | componentDidMount() { 46 | this._handleDidFocus(); 47 | } 48 | 49 | componentWillUpdate(nextProps) { 50 | if (this._routeHasChanged(this.props, nextProps)) { 51 | this._handleWillFocus(nextProps, this.props); 52 | } 53 | } 54 | 55 | componentDidUpdate(prevProps) { 56 | if (this._routeHasChanged(prevProps, this.props)) { 57 | this._handleDidFocus(); 58 | } 59 | } 60 | 61 | _handleWillFocus(nextProps, prevProps) { 62 | if (this.props.onWillFocus) { 63 | const route = this._findRoute( 64 | nextProps.navigationState.routes[ 65 | nextProps.navigationState.index].name); 66 | 67 | let prevLocation; 68 | if (prevProps) { 69 | prevLocation = prevProps.navigationState.routes[ 70 | prevProps.navigationState.index]; 71 | } 72 | 73 | this.props.onWillFocus(route, prevLocation); 74 | } 75 | } 76 | 77 | _handleDidFocus() { 78 | if (this.props.onDidFocus) { 79 | const route = this._findRoute( 80 | this.props.navigationState.routes[ 81 | this.props.navigationState.index].name); 82 | 83 | const component = this._routeComponents[route.id]; 84 | this.props.onDidFocus(route, component); 85 | } 86 | } 87 | 88 | _routeHasChanged(prevProps, nextProps) { 89 | const prevState = prevProps.navigationState; 90 | const nextState = nextProps.navigationState; 91 | 92 | return (prevState.index !== nextState.index) || (!isEqual( 93 | prevState.routes[prevState.index], 94 | nextState.routes[nextState.index], 95 | )); 96 | } 97 | 98 | render(): ReactElement { 99 | return ( 100 | 106 | ); 107 | } 108 | 109 | _findRoute(name) { 110 | const route = this.props.routes[this._routesByName[name]]; 111 | 112 | invariant(route != null, 113 | "No route found for '" + name + "'" 114 | ); 115 | 116 | return route; 117 | } 118 | 119 | _renderScene(sceneProps): ReactElement { 120 | const route = this._findRoute(sceneProps.scene.route.name); 121 | const Component = route.component; 122 | 123 | const props = { 124 | params: sceneProps.scene.route.params || {}, 125 | ref: (component) => { 126 | this._routeComponents[route.id] = component; 127 | }, 128 | }; 129 | 130 | if (this.props.createElement) { 131 | return this.props.createElement(Component, props); 132 | } 133 | 134 | return ( 135 | 136 | ); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/route.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes, Component} from "react"; 2 | 3 | export default class Route extends Component { 4 | static propTypes = { 5 | name: PropTypes.string, 6 | // component: PropTypes.function, 7 | }; 8 | 9 | render() { 10 | // Intended to serve as declarative configuration 11 | return null; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/router.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import {isEqual, uniqueId} from "lodash"; 3 | import React, {PropTypes, Component} from "react"; 4 | import {View} from "react-native"; 5 | import invariant from "invariant"; 6 | 7 | import Navigator from "./navigator"; 8 | 9 | export default class Router extends Component { 10 | static propTypes = { 11 | // Routes 12 | children: PropTypes.node.isRequired, 13 | 14 | // Router state 15 | routerState: PropTypes.object.isRequired, 16 | 17 | // Style for containing view 18 | style: View.propTypes.style, 19 | 20 | // When the router is ready to render a specific scene, 21 | // it will use this function to create the elements. 22 | createElement: PropTypes.func, 23 | 24 | // When the router is ready to render a stack of scenes, 25 | // it will use this function. 26 | render: PropTypes.func, 27 | 28 | // Focus callbacks 29 | onWillFocus: PropTypes.func, 30 | oDidFocus: PropTypes.func, 31 | }; 32 | 33 | static defaultProps = { 34 | style: {flex: 1}, 35 | }; 36 | 37 | constructor(props) { 38 | super(props); 39 | 40 | this._handleWillFocus = this._handleWillFocus.bind(this); 41 | this._handleDidFocus = this._handleDidFocus.bind(this); 42 | 43 | // The route stack here has nothing to do with the 44 | // route stack in the router state 45 | // This route stack represents the transitional state of the 46 | // onEnter and onLeave callbacks 47 | // TODO: A better name? 48 | this._routeStack = []; 49 | 50 | // Traverse the routes 51 | // NOTE: routes cannot change after construction 52 | this._routes = {}; 53 | this._traverseRoutes(React.Children.toArray(props.children)); 54 | 55 | // Cache list of parents for each route; useful in determining 56 | // onEnter and onLeave 57 | this._routeParents = {}; 58 | for (const routeId of Object.keys(this._routes)) { 59 | this._routeParents[routeId] = []; 60 | 61 | let parentId = this._routes[routeId].parent; 62 | while (parentId != null) { 63 | this._routeParents[routeId].push(parentId); 64 | parentId = this._routes[parentId].parent; 65 | } 66 | } 67 | } 68 | 69 | shouldComponentUpdate(nextProps) { 70 | return !isEqual(this.props, nextProps); 71 | } 72 | 73 | render() { 74 | if (!this.props.routerState || this.props.routerState.index == null) { 75 | return null; 76 | } 77 | 78 | return ( 79 | 88 | ); 89 | } 90 | 91 | _traverseRoutes(routes, parent) { 92 | for (const route of routes) { 93 | invariant(!!route.props.component ^ !!route.props.children, 94 | "A route may either have a component or have children", 95 | ); 96 | 97 | const id = uniqueId(); 98 | this._routes[id] = {id, parent, ...route.props}; 99 | 100 | if (route.props.children) { 101 | this._traverseRoutes( 102 | React.Children.toArray(route.props.children), id); 103 | } 104 | } 105 | } 106 | 107 | _handleDidFocus(route, component) { 108 | if (this.props.onDidFocus) { 109 | this.props.onDidFocus(route, component); 110 | } 111 | 112 | if (component && component.routerDidFocus) { 113 | component.routerDidFocus(); 114 | } 115 | } 116 | 117 | _handleWillFocus(nextRoute, prevLocation) { 118 | let currentRoute; 119 | if (this._routeStack.length > 0) { 120 | currentRoute = this._routes[ 121 | this._routeStack[this._routeStack.length - 1]]; 122 | } 123 | 124 | const nextRouteParents = this._routeParents[nextRoute.id]; 125 | 126 | // Example :: 127 | // routeStack: [1, 2, 3, 4, 6] 128 | // parents: [3, 5] 129 | // 130 | // 1 - pop 6, 4 131 | // 2 - push 5 132 | // 3 - push .id 133 | 134 | // Compare the route stack to the list of parents 135 | // We're trying to find the index in which we diverge 136 | let routeStackIndex = -1; 137 | let dIndex; 138 | for (dIndex = nextRouteParents.length - 1; dIndex >= 0; dIndex--) { 139 | if (routeStackIndex == null) { 140 | const routeStackIdx = this._routeStack.indexOf( 141 | nextRouteParents[dIndex]); 142 | 143 | if (routeStackIdx < 0) { 144 | // Drop the tables (whole stack needs to be flushed) 145 | break; 146 | } 147 | 148 | routeStackIndex = routeStackIdx; 149 | } else { 150 | routeStackIndex += 1; 151 | if (this._routeStack[routeStackIndex] !== nextRouteParents[dIndex]) { 152 | // Here is where we diverge 153 | break; 154 | } 155 | } 156 | } 157 | 158 | // 1 - Iterate from the divergence index and leave all routes 159 | if (this._routeStack.length > 0) { 160 | for (let idx = routeStackIndex; idx < this._routeStack.length; idx++) { 161 | this._popRouteFromStack(); 162 | } 163 | } 164 | 165 | // 2 - Iterate from the divergence index and add all parent routes 166 | for (; dIndex >= 0; dIndex--) { 167 | this._pushRouteToStack(nextRouteParents[dIndex], prevLocation); 168 | } 169 | 170 | // 3 - Add to the route stack 171 | this._pushRouteToStack(nextRoute.id, prevLocation); 172 | 173 | if (this.props.onWillFocus) { 174 | this.props.onWillFocus(nextRoute, currentRoute); 175 | } 176 | 177 | // Call routerWillFocus (static) on the route component 178 | if (nextRoute.component && nextRoute.component.routerWillFocus) { 179 | nextRoute.component.routerWillFocus(); 180 | } 181 | } 182 | 183 | _pushRouteToStack(id, prevLocation) { 184 | const route = this._routes[id]; 185 | 186 | if (route.onEnter != null) { 187 | route.onEnter(route, prevLocation); 188 | } 189 | 190 | this._routeStack.push(id); 191 | } 192 | 193 | _popRouteFromStack() { 194 | // Execute the onLeave of outerMost route route (if available) 195 | const lastId = this._routeStack[this._routeStack.length - 1]; 196 | const route = this._routes[lastId]; 197 | 198 | if (route.onLeave != null) { 199 | route.onLeave(); 200 | } 201 | 202 | // Pop the last route off the stack 203 | this._routeStack.splice(this._routeStack.length - 1, 1); 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/stack.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import {isEqual} from "lodash"; 3 | import React, {PropTypes} from "react"; 4 | import {View, NavigationExperimental} from "react-native"; 5 | 6 | import {fade} from "./transition"; 7 | 8 | const { 9 | Card: NavigationCard, 10 | Transitioner: NavigationTransitioner, 11 | } = NavigationExperimental; 12 | 13 | export default class Stack extends React.PureComponent { 14 | static propTypes = { 15 | navigationState: PropTypes.object.isRequired, 16 | style: View.propTypes.style, 17 | render: PropTypes.func, 18 | }; 19 | 20 | state = { 21 | inTransition: false, 22 | }; 23 | 24 | constructor(props, context) { 25 | super(props, context); 26 | 27 | this._render = this._render.bind(this); 28 | this._renderScene = this._renderScene.bind(this); 29 | this._handleTransitionStart = this._handleTransitionStart.bind(this); 30 | this._handleTransitionEnd = this._handleTransitionEnd.bind(this); 31 | } 32 | 33 | render(): ReactElement { 34 | return ( 35 | 43 | ); 44 | } 45 | 46 | _configureTransition() { 47 | return { 48 | // useNativeDriver: true, 49 | }; 50 | } 51 | 52 | _render(props): ReactElement { 53 | const {navigationState} = props; 54 | 55 | const scenes = props.scenes.filter((scene) => 56 | !scene.isStale // || scene.index === this.state.prevScene 57 | ).map((scene) => 58 | this._renderScene({ 59 | ...props, 60 | scene, 61 | }) 62 | ); 63 | 64 | if (this.props.render) { 65 | return this.props.render(scenes); 66 | } 67 | 68 | return scenes; 69 | } 70 | 71 | _renderScene(props): ReactElement { 72 | return ( 73 | 80 | ); 81 | } 82 | 83 | _handleTransitionStart(currentProps, prevProps) { 84 | this.setState({inTransition: true, currentScene: (currentProps.scene || {}).index, prevScene: (prevProps.scene || {}).index}); 85 | } 86 | 87 | _handleTransitionEnd(currentProps, prevProps) { 88 | this.setState({inTransition: false, currentScene: -1, prevScene: -1}); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/transition.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | function initial(props): Object { 4 | const { 5 | navigationState, 6 | scene, 7 | } = props; 8 | 9 | const focused = navigationState.index === scene.index; 10 | const opacity = focused ? 1 : 0; 11 | 12 | // If not focused, move the scene to the far away. 13 | const translate = focused ? 0 : 1000000; 14 | return { 15 | opacity, 16 | transform: [ 17 | {translateX: translate}, 18 | {translateY: translate}, 19 | ], 20 | }; 21 | } 22 | 23 | export function fade(props, state): Object { 24 | const { 25 | layout, 26 | position, 27 | progress, 28 | navigationState, 29 | scene, 30 | } = props; 31 | 32 | const { 33 | inTransition, 34 | } = state; 35 | 36 | if (!layout.isMeasured) { 37 | return initial(props); 38 | } 39 | 40 | const focused = navigationState.index === scene.index; 41 | 42 | const transform = []; 43 | const opacity = position.interpolate({ 44 | inputRange: [scene.index - 1, scene.index, scene.index + 1], 45 | outputRange: [0, 1, 0], 46 | }); 47 | 48 | if (!(focused || inTransition)) { 49 | transform.push({translateY: 1000000, translateX: 1000000}); 50 | } 51 | 52 | return { 53 | opacity, 54 | transform, 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /src/util-state.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import {isEqual, uniqueId} from "lodash"; 3 | import { 4 | NavigationExperimental, 5 | } from "react-native"; 6 | 7 | const { 8 | StateUtils: NavigationStateUtils, 9 | } = NavigationExperimental; 10 | 11 | export function create(options = {}) { 12 | let result = { 13 | routes: [], 14 | }; 15 | 16 | if (options.initialRoute) { 17 | result = push(result, options.initialRoute); 18 | result.index = 0; 19 | } 20 | 21 | return result; 22 | } 23 | 24 | function shrinkToFit(state) { 25 | if (state.index < (state.routes.length - 1)) { 26 | state.routes.splice(state.index + 1); 27 | } 28 | } 29 | 30 | function atRoute(state, name, params) { 31 | const currentRoute = state.routes[state.index] || {}; 32 | return (currentRoute.name === name && isEqual((currentRoute.params || {}), (params || {}))); 33 | } 34 | 35 | export function push(state: object, name: string, params: ?object): object { 36 | if (atRoute(state, name, params)) return state; 37 | 38 | shrinkToFit(state); 39 | return NavigationStateUtils.push(state, { 40 | key: "route_" + uniqueId(), 41 | name: name, 42 | params: params || {}, 43 | }); 44 | } 45 | 46 | export function pop(state: object): object { 47 | shrinkToFit(state); 48 | return NavigationStateUtils.pop(state); 49 | } 50 | 51 | /** 52 | * Push a new route on the stack UNLESS the route was previously rendered in which 53 | * case it jumps to the existing rendered route. 54 | */ 55 | export function jumpTo(state: object, name: string, params: ?object): object { 56 | let key; 57 | if (state.routes.length > 0) { 58 | for (let i = state.routes.length - 1; i >= 0; i--) { 59 | const route = state.routes[i]; 60 | if (route.name === name && isEqual(route.params, params || {})) { 61 | key = route.key; 62 | break; 63 | } 64 | } 65 | } 66 | 67 | if (!key) { 68 | return push(state, name, params); 69 | } 70 | 71 | return NavigationStateUtils.jumpTo(state, key); 72 | } 73 | 74 | /** 75 | * Reset the route stack to the new route. 76 | */ 77 | export function resetTo(state: object, name: string, params: ?object): object { 78 | return push({routes: []}, name, params); 79 | } 80 | --------------------------------------------------------------------------------