@exponent/ex-navigation
Version:
Route-centric navigation libary for React Native.
628 lines (525 loc) • 18.1 kB
JavaScript
/**
* @flow
*/
import React from 'react';
import {
Animated,
Platform,
StyleSheet,
View,
NavigationExperimental,
} from 'react-native';
import _ from 'lodash';
import invariant from 'invariant';
import cloneReferencedElement from 'react-clone-referenced-element';
import PureComponent from './utils/PureComponent';
import { debounce } from 'core-decorators';
import Actions from './ExNavigationActions';
import NavigationBar from './ExNavigationBar';
import NavigationItem from './ExNavigationStackItem';
import { getBackButtonManager } from './ExNavigationBackButtonManager';
import { createNavigatorComponent } from './ExNavigationComponents';
import ExNavigatorContext from './ExNavigatorContext';
import * as NavigationStyles from './ExNavigationStyles';
import * as Utils from './ExNavigationUtils';
const {
Transitioner: NavigationTransitioner,
} = NavigationExperimental;
import NavigationTypeDefinition from 'react-native/Libraries/NavigationExperimental/NavigationTypeDefinition';
import type {
NavigationSceneRendererProps, NavigationScene,
} from 'NavigationTypeDefinition';
import type { ExNavigationRoute } from './ExNavigationRouter';
import type ExNavigationContext from './ExNavigationContext';
import type { ExNavigationConfig, ExNavigationState } from './ExNavigationTypeDefinition';
import type { ExNavigationTabContext } from './tab/ExNavigationTab';
const DEFAULT_ROUTE_CONFIG: ExNavigationConfig = {
styles: Platform.OS !== 'android' ? NavigationStyles.FloatHorizontal : NavigationStyles.Fade,
};
type Props = {
id: string,
navigatorUID: string,
initialRoute?: ExNavigationRoute,
initialStack?: Array<ExNavigationRoute>,
navigation: ExNavigationContext,
onRegisterNavigatorContext: (navigatorUID: string, navigatorContext: ExNavigationStackContext) => void,
navigationState?: Object,
defaultRouteConfig?: ExNavigationConfig,
};
type State = {
id: string,
navigatorUID: string,
parentNavigatorUID: string,
navigatingFromIndex: number,
navigatingToIndex: number,
};
type Context = {
headerComponent: mixed,
parentNavigatorUID: string,
};
type ExNavigationSceneRendererProps = {
route: ExNavigationRoute,
} & NavigationSceneRendererProps;
let ROUTE_LISTENER_INDEX = 0;
type ExNavigationStackInstance = ReactComponent<*, *, *> & { _useAnimation: boolean, _routeListeners: { [listenerId: string]: Function } };
declare var requestAnimationFrame: () => void;
export class ExNavigationStackContext extends ExNavigatorContext {
type = 'stack';
parentNavigatorUID: string;
defaultRouteConfig: ExNavigationConfig;
componentInstance: ExNavigationStackInstance;
_getNavigatorState: any;
constructor(
navigatorUID: string,
parentNavigatorUID: string,
navigatorId: string,
navigationContext: ExNavigationContext,
componentInstance: ExNavigationStackInstance,
) {
super(navigatorUID, parentNavigatorUID, navigatorId, navigationContext);
this.navigatorUID = navigatorUID;
this.parentNavigatorUID = parentNavigatorUID;
this.navigatorId = navigatorId;
this.navigationContext = navigationContext;
this.componentInstance = componentInstance;
}
push(route: ExNavigationRoute) {
invariant(route !== null && route.key, 'Route is null or malformed.');
this.navigationContext.performAction(({ stacks }) => {
stacks(this.navigatorUID).push(route);
});
}
pop() {
this.navigationContext.performAction(({ stacks }) => {
stacks(this.navigatorUID).pop();
});
}
replace(route: ExNavigationRoute) {
invariant(route !== null && route.key, 'Route is null or malformed.');
this.componentInstance._useAnimation = false;
this.navigationContext.performAction(({ stacks }) => {
stacks(this.navigatorUID).replace(route);
});
requestAnimationFrame(() => {
this.componentInstance._useAnimation = true;
});
}
getCurrentRoute() {
const navigatorState = this._getNavigatorState();
if (!navigatorState) {
throw new Error('Navigation state for this navigator does not exist.');
}
return navigatorState.routes[navigatorState.index];
}
getCurrentIndex() {
const navigatorState = this._getNavigatorState();
if (!navigatorState) {
throw new Error('Navigation state for this navigator does not exist.');
}
return navigatorState.index;
}
addRouteListener(listener: Function) {
let listenerId = String(ROUTE_LISTENER_INDEX++);
this.componentInstance._routeListeners[listenerId] = listener;
return {
remove: () => {
delete this.componentInstance._routeListeners[listenerId];
},
};
}
immediatelyResetStack(routes: Array<ExNavigationRoute>, index: number = 0) {
this.componentInstance._useAnimation = false;
this.navigationContext.performAction(({ stacks }) => {
stacks(this.navigatorUID).immediatelyResetStack(routes, index);
});
requestAnimationFrame(() => {
this.componentInstance._useAnimation = true;
});
}
showLocalAlert = (message: string, options: mixed) => {
this.navigationContext.performAction(({ stacks }) => {
stacks(this.navigatorUID).showLocalAlert(message, options);
});
};
hideLocalAlert = () => {
this.navigationContext.performAction(({ stacks }) => {
stacks(this.navigatorUID).hideLocalAlert();
});
}
updateCurrentRouteParams(newParams: Object) {
this.navigationContext.performAction(({ stacks }) => {
stacks(this.navigatorUID).updateCurrentRouteParams(newParams);
});
}
}
class ExNavigationStack extends PureComponent<any, Props, State> {
props: Props;
state: State;
context: Context;
_log: Function;
_routeListeners: {
[key: string]: Function
};
_useAnimation: boolean;
static route: ExNavigationConfig = {
__isNavigator: true,
};
static defaultProps = {
defaultRouteConfig: DEFAULT_ROUTE_CONFIG,
};
static contextTypes = {
parentNavigatorUID: React.PropTypes.string,
headerComponent: React.PropTypes.func,
};
static childContextTypes = {
parentNavigatorUID: React.PropTypes.string,
navigator: React.PropTypes.instanceOf(ExNavigationStackContext),
headerComponent: React.PropTypes.func,
};
getChildContext() {
return {
// Get the navigator actions instance for this navigator
navigator: this._getNavigatorContext(),
parentNavigatorUID: this.state.navigatorUID,
headerComponent: this.props.headerComponent || this.context.headerComponent,
};
}
constructor(props: Props, context: Context) {
super(props, context);
this.state = {
id: props.id,
navigatorUID: props.navigatorUID,
parentNavigatorUID: context.parentNavigatorUID,
navigatingFromIndex: -1,
navigatingToIndex: 0,
};
this._routeListeners = {};
this._useAnimation = true;
this._log = Utils.createLogger('StackNavigation:' + this.state.navigatorUID);
}
render() {
const navigationState: ?Object = this.props.navigationState;
if (!navigationState) {
return null;
}
return (
<NavigationTransitioner
style={styles.container}
navigationState={navigationState}
render={this._renderTransitioner}
configureTransition={this._configureTransition}
/>
);
}
componentWillMount() {
this._registerNavigatorContext();
const { initialStack, initialRoute } = this.props;
invariant(
initialRoute || initialStack,
`You must specify initialRoute or initialStack to initialize this StackNavigation.`
);
invariant(
(initialRoute && !initialStack) || (!initialRoute && initialStack),
`Only specify one of 'initialRoute' or 'initialStack' when initializing StackNavigation.`
);
let routes: Array<ExNavigationRoute> = [];
if (initialStack) {
routes = initialStack;
} else if (initialRoute) {
routes = [
initialRoute,
];
}
let stack = routes;
if (this.props.navigationState) {
stack = [
...routes,
...this.props.navigationState.routes,
];
}
this.props.navigation.dispatch(Actions.setCurrentNavigator(
this.state.navigatorUID,
this.state.parentNavigatorUID,
'stack',
this._getDefaultRouteConfig(),
stack,
stack.length - 1,
));
if (this.state.parentNavigatorUID) {
const parentNavigator = this.props.navigation.getNavigatorByUID(this.state.parentNavigatorUID);
if (parentNavigator.type === 'tab') {
((parentNavigator: any): ExNavigationTabContext).setNavigatorUIDForCurrentTab(this.state.navigatorUID);
}
}
getBackButtonManager().ensureGlobalListener();
}
componentWillUnmount() {
this.props.navigation.dispatch(Actions.removeNavigator(this.state.navigatorUID));
}
componentWillReceiveProps(nextProps: Props) {
const prevNavigationState: ?Object = this.props.navigationState;
const nextNavigationState: ?Object = nextProps.navigationState;
if (prevNavigationState && nextNavigationState && prevNavigationState.index !== nextNavigationState.index) {
_.forEach(this._routeListeners, (listener) => {
listener(prevNavigationState, nextNavigationState);
});
this.setState({
navigatingFromIndex: prevNavigationState.index,
navigatingToIndex: nextNavigationState.index,
});
}
}
_configureTransition = (transitionProps, prevTransitionProps) => {
if (!this._useAnimation) {
return {
duration: 0,
};
}
// Gross...should figure out a way to make this stuff better TODO @skevy
// In general though, we're getting route config (and thus, animation config) from the latest
// scene, so that we know how to apply the animation.
const navigationState: ?Object = this.props.navigationState;
if (!navigationState) {
return null;
}
const latestRoute = navigationState.routes[navigationState.routes.length - 1];
const latestRouteConfig = latestRoute.config;
const { configureTransition } = latestRouteConfig.styles || {};
if (typeof configureTransition === 'function') {
return configureTransition(transitionProps, prevTransitionProps);
}
};
_registerNavigatorContext() {
this.props.onRegisterNavigatorContext(this.state.navigatorUID,
new ExNavigationStackContext(
this.state.navigatorUID,
this.state.parentNavigatorUID,
this.state.id,
this.props.navigation,
this,
)
);
}
_onNavigateBack = () => {
this._getNavigatorContext().pop();
};
_renderTransitioner = (props) => {
const overlay = this._renderOverlay({
...props,
scene: props.scene,
});
const scenes = props.scenes.map(
scene => this._renderScene({
...props,
scene,
})
);
return (
<View
style={styles.container}>
<View
style={styles.scenes}>
{scenes}
</View>
{overlay}
</View>
);
}
_renderOverlay = (props: ExNavigationSceneRendererProps) => {
// Determine animation styles based on the most recent scene in the stack.
const latestRoute = this._getRouteAtIndex(props.scenes, props.scenes.length - 1);
const latestRouteConfig: ExNavigationConfig = latestRoute.config;
props = { ...props, latestRouteConfig, latestRoute };
if (typeof this.props.renderOverlay === 'function') {
return this.props.renderOverlay(props);
}
let interpolator = null;
if (latestRouteConfig.styles) {
interpolator = latestRouteConfig.styles.navigationBarAnimations;
if (latestRouteConfig.navigationBar && latestRouteConfig.navigationBar.styles) {
interpolator = latestRouteConfig.navigationBar.styles;
}
}
// Get HeaderComponent from props/context
const HeaderComponent = this.props.headerComponent || this.context.headerComponent || NavigationBar;
const navigationBarIsVisible =
latestRouteConfig.navigationBar &&
latestRouteConfig.navigationBar.visible !== false;
// TODO: add height and statusBarHeight options here
return (
<HeaderComponent
{...props}
getNavigatorContext={this._getNavigatorContext}
navigatorUID={this.state.navigatorUID}
visible={navigationBarIsVisible}
interpolator={interpolator}
renderLeftComponent={this._renderLeftComponentForHeader}
renderTitleComponent={this._renderTitleComponentForHeader}
renderRightComponent={this._renderRightComponentForHeader}
/>
);
};
_drawerNavigatorParent = () => {
let result;
let currentNavigator = this._getNavigatorContext();
while (currentNavigator) {
try {
currentNavigator = currentNavigator.getParentNavigator();
} catch (e) {
break;
}
if (currentNavigator.type === 'drawer') {
result = currentNavigator;
break;
}
}
return result;
}
_renderLeftComponentForHeader = (props) => { //eslint-disable-line react/display-name
const { scene: { route } } = props;
const routeConfig = route.config;
if (routeConfig.navigationBar && typeof routeConfig.navigationBar.renderLeft === 'function') {
return routeConfig.navigationBar.renderLeft(route, props);
}
const drawerNavigatorParent = this._drawerNavigatorParent();
if (props.scene.index === 0 && !!drawerNavigatorParent) {
return (
<NavigationBar.MenuButton
navigator={drawerNavigatorParent}
tintColor={route.getBarTintColor()}
/>
);
}
if (props.scene.index > 0) {
return (
<NavigationBar.BackButton tintColor={route.getBarTintColor()} />
);
} else {
return null;
}
};
_renderTitleComponentForHeader = (props) => { //eslint-disable-line react/display-name
const { scene: { route } } = props;
const routeConfig = route.config;
if (routeConfig.navigationBar && typeof routeConfig.navigationBar.renderTitle === 'function') {
return routeConfig.navigationBar.renderTitle(route, props);
}
return (
<NavigationBar.Title textStyle={route.getTitleStyle()} tintColor={route.getBarTintColor()}>
{route.getTitle()}
</NavigationBar.Title>
);
};
_renderRightComponentForHeader = (props) => {
const { scene: { route } } = props;
const routeConfig = route.config;
return routeConfig.navigationBar && routeConfig.navigationBar.renderRight && routeConfig.navigationBar.renderRight(route, props);
};
_renderScene = (props: ExNavigationSceneRendererProps) => {
// Determine gesture and animation styles based on the most recent scene in the stack,
// not based on the scene we're rendering in this method.
const latestRoute = this._getRouteAtIndex(props.scenes, props.scenes.length - 1);
const latestRouteConfig = latestRoute.config;
const { sceneAnimations, gestures } = latestRouteConfig.styles || {};
props = { ...props, latestRouteConfig, latestRoute };
const scene: any = props.scene;
const routeForScene = scene.route;
return (
<NavigationItem
{...props}
onNavigateBack={this._onNavigateBack}
key={props.scene.key}
route={routeForScene}
sceneAnimations={sceneAnimations}
gestures={gestures}
renderScene={this._renderRoute}
/>
);
};
_renderRoute = (props: ExNavigationSceneRendererProps) => {
const route: ExNavigationRoute = props.route;
const routeElement = route.render();
let routeElementProps = {};
const routeConfig = route.config;
routeElementProps = {
route,
};
let style = [
styles.routeInnerContainer,
];
if (routeConfig.navigationBar && routeConfig.navigationBar.visible !== false) {
let customHeight = 0;
let hasCustomHeight = false;
let isTranslucent = !!routeConfig.navigationBar.translucent;
if (_.isNumber(route.getBarHeight())) {
customHeight += route.getBarHeight();
hasCustomHeight = true;
}
if (hasCustomHeight) {
style = ([...style, {marginTop: customHeight}] : Array<number|Object>);
} else {
style = [...style, isTranslucent ? styles.withNavigationBarTranslucent : styles.withNavigationBarOpaque];
}
} else {
style = [...style, styles.withoutNavigationBar];
}
if (routeConfig.sceneStyle) {
style = [...style, routeConfig.sceneStyle || styles.defaultSceneStyle];
}
return (
<View style={styles.routeContainer}>
<Animated.View style={style}>
<View style={{ flex: 1 }}>
{cloneReferencedElement(routeElement, routeElementProps)}
</View>
</Animated.View>
</View>
);
};
_getRouteAtIndex(scenes: Array<NavigationScene>, index: number): ExNavigationRoute {
const scene: any = scenes[index];
const latestRoute: ExNavigationRoute = scene.route;
return latestRoute;
}
_getDefaultRouteConfig(props) {
if (!props) {
props = this.props;
}
return _.merge({}, DEFAULT_ROUTE_CONFIG, props.defaultRouteConfig);
}
// TODO: fix this type annotation to return the actual type
_getNavigatorContext = (): any => {
return this.props.navigation.getNavigatorByUID(this.state.navigatorUID);
}
}
export default createNavigatorComponent(ExNavigationStack);
const styles = StyleSheet.create({
container: {
flex: 1,
},
scenes: {
flex: 1,
},
defaultSceneStyle: {
backgroundColor: '#fff',
},
routeContainer: {
flex: 1,
},
routeInnerContainer: {
flex: 1,
},
withoutNavigationBar: {
marginTop: 0,
},
withNavigationBarTranslucent: {
paddingTop: 0,
},
withNavigationBarOpaque: {
// TODO: needs to be dynamic based off of current navbar height
paddingTop: NavigationBar.DEFAULT_HEIGHT,
},
});