@exponent/ex-navigation
Version:
Route-centric navigation library for React Native.
860 lines (714 loc) • 25.4 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 ExNavigationAlertBar from './ExNavigationAlertBar';
import * as NavigationStyles from './ExNavigationStyles';
import SharedElementGroup from './shared-element/ExNavigationSharedElementGroup';
const {
Transitioner: NavigationTransitioner,
} = NavigationExperimental;
import type {
NavigationSceneRendererProps, NavigationScene, NavigationTransitionProps,
} from 'NavigationTypeDefinition';
import type { ExNavigationRoute, ExNavigationRouter } from './ExNavigationRouter';
import type ExNavigationContext from './ExNavigationContext';
import type { ExNavigationConfig } from './ExNavigationTypeDefinition';
import type { ExNavigationTabContext } from './tab/ExNavigationTab';
const DEFAULT_ROUTE_CONFIG: ExNavigationConfig = {
styles: Platform.OS === 'ios' ? NavigationStyles.SlideHorizontal : NavigationStyles.Fade,
};
const DEFAULT_STATUSBAR_HEIGHT = Platform.OS === 'ios' ? 20 : 25;
const STATUSBAR_HEIGHT = Platform.OS === 'ios'
? DEFAULT_STATUSBAR_HEIGHT
: (global.__exponent ? DEFAULT_STATUSBAR_HEIGHT : 0);
type TransitionFn = (
transitionProps: NavigationTransitionProps,
prevTransitionProps: NavigationTransitionProps
) => void;
type Props = {
augmentScene?: (scene: ReactElement<any>, route: Object) => ReactElement<any>,
defaultRouteConfig?: ExNavigationConfig,
id: string,
initialRoute?: ExNavigationRoute,
initialStack?: Array<ExNavigationRoute>,
navigation: ExNavigationContext,
navigationState?: Object,
navigatorUID: string,
onRegisterNavigatorContext: (navigatorUID: string, navigatorContext: ExNavigationStackContext) => void,
onUnregisterNavigatorContext: (navigatorUID: string) => void,
onTransitionStart: ?TransitionFn,
onTransitionEnd: ?TransitionFn,
renderScene?: (props: StackNavigationSceneRendererProps) => ?React.Element<{}>,
};
type State = {
id: string,
navigatorUID: string,
parentNavigatorUID: string,
navigatingFromIndex: number,
navigatingToIndex: number,
};
type Context = {
headerComponent: mixed,
alertBarComponent: mixed,
parentNavigatorUID: string,
};
type ExNavigationSceneRendererProps = {
route: ExNavigationRoute,
} & NavigationSceneRendererProps;
type StackNavigationSceneRendererProps = ExNavigationSceneRendererProps & {
style?: any,
};
type TransitionOptions = {
transitionGroup?: string,
};
let ROUTE_LISTENER_INDEX = 0;
type ExNavigationStackInstance = React.Component<*, *, *> & { _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;
}
get router():ExNavigationRouter<*> {
return this.navigationContext.router;
}
push(
route: (ExNavigationRoute | string),
paramsOrOptions?: (Object | TransitionOptions),
options?: TransitionOptions
) {
if (typeof route == 'string') {
route = this.router.getRoute(route, paramsOrOptions);
} else {
options = paramsOrOptions;
}
options = options || {};
invariant(route !== null && route.key, 'Route is null or malformed.');
if (options.transitionGroup) {
route.config.styles = SharedElementGroup.getRouteStyle(options.transitionGroup);
}
this.navigationContext.performAction(({ stacks }) => {
stacks(this.navigatorUID).push(route);
});
}
pop(n: number = 1) {
this.navigationContext.performAction(({ stacks }) => {
stacks(this.navigatorUID).pop(n);
});
}
popToTop() {
this.navigationContext.performAction(({ stacks }) => {
stacks(this.navigatorUID).popToTop();
});
}
replace(route: (ExNavigationRoute | string), params?: Object) {
if (typeof route == 'string') {
route = this.router.getRoute(route, params);
}
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,
onTransitionEnd: () => {},
onTransitionStart: () => {},
};
static contextTypes = {
parentNavigatorUID: React.PropTypes.string,
headerComponent: React.PropTypes.func,
alertBarComponent: React.PropTypes.func,
};
static childContextTypes = {
parentNavigatorUID: React.PropTypes.string,
navigator: React.PropTypes.instanceOf(ExNavigationStackContext),
headerComponent: React.PropTypes.func,
alertBarComponent: 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,
alertBarComponent: this.props.alertBarComponent || this.context.alertBarComponent,
};
}
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;
}
render() {
const navigationState: ?Object = this.props.navigationState;
if (!navigationState) {
return null;
}
return (
<NavigationTransitioner
style={styles.container}
navigationState={navigationState}
render={this._renderTransitioner}
configureTransition={this._configureTransition}
onTransitionStart={this._onTransitionStart}
onTransitionEnd={this._onTransitionEnd}
/>
);
}
componentWillMount() {
this._registerNavigatorContext();
const { initialStack } = this.props;
let { 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) {
// initialRoute can be passed in as strings
if (typeof initialRoute === 'string') {
initialRoute = this._getNavigatorContext().router.getRoute(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);
}
else if(parentNavigator.type === 'drawer') {
((parentNavigator: any): ExNavigationDrawerContext).setNavigatorUIDForCurrentItem(this.state.navigatorUID);
}
}
getBackButtonManager().ensureGlobalListener();
}
componentWillUnmount() {
this.props.navigation.dispatch(Actions.removeNavigator(this.state.navigatorUID));
this.props.onUnregisterNavigatorContext(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 = transitionProps.scenes[transitionProps.scenes.length - 1].route;
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 header = this._renderHeader({
...props,
scene: props.scene,
});
const alertBar = this._renderAlertBar({
...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>
{header}
{alertBar}
</View>
);
}
_getNavigationBarHeight(latestRouteConfig) {
let height = NavigationBar.DEFAULT_HEIGHT;
if (latestRouteConfig.navigationBar && latestRouteConfig.navigationBar.height) {
height = latestRouteConfig.navigationBar.height + DEFAULT_STATUSBAR_HEIGHT;
}
if (latestRouteConfig.statusBar && latestRouteConfig.statusBar.translucent) {
height = NavigationBar.DEFAULT_HEIGHT_WITHOUT_STATUS_BAR + DEFAULT_STATUSBAR_HEIGHT;
}
return height;
}
_renderAlertBar = (props: ExNavigationSceneRendererProps) => {
const latestRoute = this._getRouteAtIndex(props.scenes, props.scenes.length - 1);
const latestRouteConfig: ExNavigationConfig = latestRoute.config;
const navigationBarIsVisible =
latestRouteConfig.navigationBar &&
latestRouteConfig.navigationBar.visible !== false;
const AlertBarComponent = this.props.alertBarComponent || this.context.alertBarComponent || ExNavigationAlertBar;
const alertBarContainerStyle = [
styles.alertBarContainer,
{ top: navigationBarIsVisible ? this._getNavigationBarHeight(latestRouteConfig): 0 },
];
return (
<View style={alertBarContainerStyle}>
<AlertBarComponent
style={navigationBarIsVisible ? null : {paddingTop: STATUSBAR_HEIGHT}}
getNavigatorContext={this._getNavigatorContext}
navigatorUID={this.state.navigatorUID}
/>
</View>
);
}
_renderHeader = (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;
// pass the statusBarHeight to headerComponent if statusBar is translucent
let statusBarHeight = STATUSBAR_HEIGHT;
if (latestRouteConfig.statusBar && latestRouteConfig.statusBar.translucent) {
statusBarHeight = DEFAULT_STATUSBAR_HEIGHT;
}
// TODO: add height here
return (
<HeaderComponent
{...props}
statusBarHeight={statusBarHeight}
getNavigatorContext={this._getNavigatorContext}
navigatorUID={this.state.navigatorUID}
visible={navigationBarIsVisible}
interpolator={interpolator}
renderLeftComponent={this._renderLeftComponentForHeader}
renderTitleComponent={this._renderTitleComponentForHeader}
renderRightComponent={this._renderRightComponentForHeader}
renderBackgroundComponent={this._renderBackgroundComponentForHeader}
/>
);
};
_drawerNavigatorParent = () => {
let result;
let currentNavigator = this._getNavigatorContext();
while (currentNavigator) {
try {
currentNavigator = currentNavigator.getParentNavigator();
} catch (e) {
break;
}
if (currentNavigator && currentNavigator.type === 'drawer') {
result = currentNavigator;
break;
}
}
return result;
}
_renderBackgroundComponentForHeader = (props) => { //eslint-disable-line react/display-name
const { scene: { route } } = props;
const routeConfig = route.config;
if (routeConfig.navigationBar && typeof routeConfig.navigationBar.renderBackground === 'function') {
let maybeBackgroundComponent = routeConfig.navigationBar.renderBackground(route, props);
if (maybeBackgroundComponent) {
return maybeBackgroundComponent;
}
return null;
}
};
_renderLeftComponentForHeader = (props) => { //eslint-disable-line react/display-name
const { scene: { route } } = props;
const routeConfig = route.config;
if (routeConfig.navigationBar && typeof routeConfig.navigationBar.renderLeft === 'function') {
let maybeLeftComponent = routeConfig.navigationBar.renderLeft(route, props);
if (maybeLeftComponent) {
return maybeLeftComponent;
}
return null;
}
let menuButton = this._maybeRenderMenuButton('left', route, props);
if (menuButton) {
return menuButton;
}
if (props.scene.index > 0) {
return (
<NavigationBar.BackButton tintColor={route.getBarTintColor()} />
);
}
return null;
};
_maybeRenderMenuButton = (position, route, props) => {
const drawerNavigatorParent = this._drawerNavigatorParent();
if (props.scene.index === 0 && !!drawerNavigatorParent) {
// Don't render the button on the left if the drawerPosition is on the
// right, and vice versa
if (drawerNavigatorParent.options.drawerPosition !== position) {
return;
}
return (
<NavigationBar.MenuButton
navigator={drawerNavigatorParent}
tintColor={route.getBarTintColor()}
/>
);
}
}
_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;
if (routeConfig.navigationBar && typeof routeConfig.navigationBar.renderRight === 'function') {
let maybeRightComponent = routeConfig.navigationBar.renderRight(route, props);
if (maybeRightComponent) {
return maybeRightComponent;
}
return null;
}
let menuButton = this._maybeRenderMenuButton('right', route, props);
if (menuButton) {
return menuButton;
}
return null;
};
_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 || {};
const scene: any = props.scene;
const routeForScene = scene.route;
props = {
...props,
latestRouteConfig,
latestRoute,
onNavigateBack: this._onNavigateBack,
key: props.scene.key,
route: routeForScene,
sceneAnimations,
gestures,
renderScene: this._renderRoute,
};
if (typeof this.props.renderScene === 'function') {
return this.props.renderScene(props);
}
return (
<NavigationItem {...props} />
);
};
_renderRoute = (props: ExNavigationSceneRendererProps) => {
const route: ExNavigationRoute = props.route;
let routeElement = route.render();
if (this.props.augmentScene) {
routeElement = this.props.augmentScene(routeElement, route);
}
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
: { paddingTop: this._getNavigationBarHeight(routeConfig) },
];
}
} 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>
);
};
_onTransitionStart = (transitionProps, prevTransitionProps) => {
const { route: nextRoute } = transitionProps.scene;
const nextRouteConfig = nextRoute.config;
if (nextRouteConfig.styles &&
nextRouteConfig.styles.onTransitionStart) {
nextRouteConfig.styles.onTransitionStart(transitionProps, prevTransitionProps);
}
if (prevTransitionProps) {
const { route: prevRoute } = prevTransitionProps.scene;
const prevRouteConfg = prevRoute.config;
if (prevRouteConfg.styles &&
prevRouteConfg.styles.onTransitionStart) {
prevRouteConfg.styles.onTransitionStart(transitionProps, prevTransitionProps);
}
}
if (this.props.onTransitionStart) {
this.props.onTransitionStart(transitionProps, prevTransitionProps);
}
};
_onTransitionEnd = (transitionProps, prevTransitionProps) => {
const { route: nextRoute } = transitionProps.scene;
const nextRouteConfig = nextRoute.config;
if (nextRouteConfig.styles &&
nextRouteConfig.styles.onTransitionEnd) {
nextRouteConfig.styles.onTransitionEnd(transitionProps, prevTransitionProps);
}
if (prevTransitionProps) {
const { route: prevRoute } = prevTransitionProps.scene;
const prevRouteConfg = prevRoute.config;
if (prevRouteConfg.styles &&
prevRouteConfg.styles.onTransitionEnd) {
prevRouteConfg.styles.onTransitionEnd(transitionProps, prevTransitionProps);
}
}
if (this.props.onTransitionEnd) {
this.props.onTransitionEnd(transitionProps, prevTransitionProps);
}
};
_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,
},
alertBarContainer: {
position: 'absolute',
left: 0,
right: 0,
},
});