react-native/Libraries/CustomComponents/Navigator/Navigator.js

1367 lines
46 KiB
JavaScript

/**
* Copyright (c) 2013-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*
* Facebook, Inc. ("Facebook") owns all right, title and interest, including
* all intellectual property and other proprietary rights, in and to the React
* Native CustomComponents software (the "Software"). Subject to your
* compliance with these terms, you are hereby granted a non-exclusive,
* worldwide, royalty-free copyright license to (1) use and copy the Software;
* and (2) reproduce and distribute the Software as part of your own software
* ("Your Software"). Facebook reserves all rights not expressly granted to
* you in this license agreement.
*
* THE SOFTWARE AND DOCUMENTATION, IF ANY, ARE PROVIDED "AS IS" AND ANY EXPRESS
* OR IMPLIED WARRANTIES (INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE) ARE DISCLAIMED.
* IN NO EVENT SHALL FACEBOOK OR ITS AFFILIATES, OFFICERS, DIRECTORS OR
* EMPLOYEES BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
* OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
* OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THE SOFTWARE, EVEN IF
* ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* @providesModule Navigator
*/
/* eslint-disable no-extra-boolean-cast*/
'use strict';
var AnimationsDebugModule = require('NativeModules').AnimationsDebugModule;
var Dimensions = require('Dimensions');
var InteractionMixin = require('InteractionMixin');
var NavigationContext = require('NavigationContext');
var NavigatorBreadcrumbNavigationBar = require('NavigatorBreadcrumbNavigationBar');
var NavigatorNavigationBar = require('NavigatorNavigationBar');
var NavigatorSceneConfigs = require('NavigatorSceneConfigs');
var PanResponder = require('PanResponder');
var React = require('React');
var StyleSheet = require('StyleSheet');
var Subscribable = require('Subscribable');
var TVEventHandler = require('TVEventHandler');
var TimerMixin = require('react-timer-mixin');
var View = require('View');
var clamp = require('clamp');
var flattenStyle = require('flattenStyle');
var invariant = require('fbjs/lib/invariant');
var rebound = require('rebound');
var PropTypes = React.PropTypes;
// TODO: this is not ideal because there is no guarantee that the navigator
// is full screen, however we don't have a good way to measure the actual
// size of the navigator right now, so this is the next best thing.
var SCREEN_WIDTH = Dimensions.get('window').width;
var SCREEN_HEIGHT = Dimensions.get('window').height;
var SCENE_DISABLED_NATIVE_PROPS = {
pointerEvents: 'none',
style: {
top: SCREEN_HEIGHT,
bottom: -SCREEN_HEIGHT,
opacity: 0,
},
};
var __uid = 0;
function getuid() {
return __uid++;
}
function getRouteID(route) {
if (route === null || typeof route !== 'object') {
return String(route);
}
var key = '__navigatorRouteID';
if (!route.hasOwnProperty(key)) {
Object.defineProperty(route, key, {
enumerable: false,
configurable: false,
writable: false,
value: getuid(),
});
}
return route[key];
}
// styles moved to the top of the file so getDefaultProps can refer to it
var styles = StyleSheet.create({
container: {
flex: 1,
overflow: 'hidden',
},
defaultSceneStyle: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
top: 0,
transform: [
{translateX: 0},
{translateY: 0},
{scaleX: 1},
{scaleY: 1},
{rotate: '0deg'},
{skewX: '0deg'},
{skewY: '0deg'},
],
},
baseScene: {
position: 'absolute',
overflow: 'hidden',
left: 0,
right: 0,
bottom: 0,
top: 0,
},
disabledScene: {
top: SCREEN_HEIGHT,
bottom: -SCREEN_HEIGHT,
},
transitioner: {
flex: 1,
backgroundColor: 'transparent',
overflow: 'hidden',
}
});
var GESTURE_ACTIONS = [
'pop',
'jumpBack',
'jumpForward',
];
/**
* `Navigator` handles the transition between different scenes in your app.
* It is implemented in JavaScript and is available on both iOS and Android. If
* you are targeting iOS only, you may also want to consider using
* [`NavigatorIOS`](docs/navigatorios.html) as it leverages native UIKit
* navigation.
*
* To set up the `Navigator` you provide one or more objects called routes,
* to identify each scene. You also provide a `renderScene` function that
* renders the scene for each route object.
*
* ```
* import React, { Component } from 'react';
* import { Text, Navigator, TouchableHighlight } from 'react-native';
*
* export default class NavAllDay extends Component {
* render() {
* return (
* <Navigator
* initialRoute={{ title: 'Awesome Scene', index: 0 }}
* renderScene={(route, navigator) =>
* <Text>Hello {route.title}!</Text>
* }
* style={{padding: 100}}
* />
* );
* }
* }
* ```
*
* In the above example, `initialRoute` is used to specify the first route. It
* contains a `title` property that identifies the route. The `renderScene`
* prop returns a function that displays text based on the route's title.
*
* ### Additional Scenes
*
* The first example demonstrated one scene. To set up multiple scenes, you pass
* the `initialRouteStack` prop to `Navigator`:
*
* ```
* render() {
* const routes = [
* {title: 'First Scene', index: 0},
* {title: 'Second Scene', index: 1},
* ];
* return (
* <Navigator
* initialRoute={routes[0]}
* initialRouteStack={routes}
* renderScene={(route, navigator) =>
* <TouchableHighlight onPress={() => {
* if (route.index === 0) {
* navigator.push(routes[1]);
* } else {
* navigator.pop();
* }
* }}>
* <Text>Hello {route.title}!</Text>
* </TouchableHighlight>
* }
* style={{padding: 100}}
* />
* );
* }
* ```
*
* In the above example, a `routes` variable is defined with two route objects
* representing two scenes. Each route has an `index` property that is used to
* manage the scene being rendered. The `renderScene` method is changed to
* either push or pop the navigator depending on the current route's index.
* Finally, the `Text` component in the scene is now wrapped in a
* `TouchableHighlight` component to help trigger the navigator transitions.
*
* ### Navigation Bar
*
* You can optionally pass in your own navigation bar by returning a
* `Navigator.NavigationBar` component to the `navigationBar` prop in
* `Navigator`. You can configure the navigation bar properties, through
* the `routeMapper` prop. There you set up the left, right, and title
* properties of the navigation bar:
*
* ```
* <Navigator
* renderScene={(route, navigator) =>
* // ...
* }
* navigationBar={
* <Navigator.NavigationBar
* routeMapper={{
* LeftButton: (route, navigator, index, navState) =>
* { return (<Text>Cancel</Text>); },
* RightButton: (route, navigator, index, navState) =>
* { return (<Text>Done</Text>); },
* Title: (route, navigator, index, navState) =>
* { return (<Text>Awesome Nav Bar</Text>); },
* }}
* style={{backgroundColor: 'gray'}}
* />
* }
* />
* ```
*
* When configuring the left, right, and title items for the navigation bar,
* you have access to info such as the current route object and navigation
* state. This allows you to customize the title for each scene as well as
* the buttons. For example, you can choose to hide the left button for one of
* the scenes.
*
* Typically you want buttons to represent the left and right buttons. Building
* on the previous example, you can set this up as follows:
*
* ```
* LeftButton: (route, navigator, index, navState) =>
* {
* if (route.index === 0) {
* return null;
* } else {
* return (
* <TouchableHighlight onPress={() => navigator.pop()}>
* <Text>Back</Text>
* </TouchableHighlight>
* );
* }
* },
* ```
*
* This sets up a left navigator bar button that's visible on scenes after the
* the first one. When the button is tapped the navigator is popped.
*
* Another type of navigation bar, with breadcrumbs, is provided by
* `Navigator.BreadcrumbNavigationBar`. You can also provide your own navigation
* bar by passing it through the `navigationBar` prop. See the
* [UIExplorer](https://github.com/facebook/react-native/tree/master/Examples/UIExplorer)
* demo to try out both built-in navigation bars out and see how to use them.
*
* ### Scene Transitions
*
* To change the animation or gesture properties of the scene, provide a
* `configureScene` prop to get the config object for a given route:
*
* ```
* <Navigator
* renderScene={(route, navigator) =>
* // ...
* }
* configureScene={(route, routeStack) =>
* Navigator.SceneConfigs.FloatFromBottom}
* />
* ```
* In the above example, the newly pushed scene will float up from the bottom.
* See `Navigator.SceneConfigs` for default animations and more info on
* available [scene config options](docs/navigator.html#configurescene).
*/
var Navigator = React.createClass({
propTypes: {
/**
* Optional function where you can configure scene animations and
* gestures. Will be invoked with `route` and `routeStack` parameters,
* where `route` corresponds to the current scene being rendered by the
* `Navigator` and `routeStack` is the set of currently mounted routes
* that the navigator could transition to.
*
* The function should return a scene configuration object.
*
* ```
* (route, routeStack) => Navigator.SceneConfigs.FloatFromRight
* ```
*
* Available scene configuration options are:
*
* - Navigator.SceneConfigs.PushFromRight (default)
* - Navigator.SceneConfigs.FloatFromRight
* - Navigator.SceneConfigs.FloatFromLeft
* - Navigator.SceneConfigs.FloatFromBottom
* - Navigator.SceneConfigs.FloatFromBottomAndroid
* - Navigator.SceneConfigs.FadeAndroid
* - Navigator.SceneConfigs.SwipeFromLeft
* - Navigator.SceneConfigs.HorizontalSwipeJump
* - Navigator.SceneConfigs.HorizontalSwipeJumpFromRight
* - Navigator.SceneConfigs.HorizontalSwipeJumpFromLeft
* - Navigator.SceneConfigs.VerticalUpSwipeJump
* - Navigator.SceneConfigs.VerticalDownSwipeJump
*
*/
configureScene: PropTypes.func,
/**
* Required function which renders the scene for a given route. Will be
* invoked with the `route` and the `navigator` object.
*
* ```
* (route, navigator) =>
* <MySceneComponent title={route.title} navigator={navigator} />
* ```
*/
renderScene: PropTypes.func.isRequired,
/**
* The initial route for navigation. A route is an object that the navigator
* will use to identify each scene it renders.
*
* If both `initialRoute` and `initialRouteStack` props are passed to
* `Navigator`, then `initialRoute` must be in a route in
* `initialRouteStack`. If `initialRouteStack` is passed as a prop but
* `initialRoute` is not, then `initialRoute` will default internally to
* the last item in `initialRouteStack`.
*/
initialRoute: PropTypes.object,
/**
* Pass this in to provide a set of routes to initially mount. This prop
* is required if `initialRoute` is not provided to the navigator. If this
* prop is not passed in, it will default internally to an array
* containing only `initialRoute`.
*/
initialRouteStack: PropTypes.arrayOf(PropTypes.object),
/**
* Pass in a function to get notified with the target route when
* the navigator component is mounted and before each navigator transition.
*/
onWillFocus: PropTypes.func,
/**
* Will be called with the new route of each scene after the transition is
* complete or after the initial mounting.
*/
onDidFocus: PropTypes.func,
/**
* Use this to provide an optional component representing a navigation bar
* that is persisted across scene transitions. This component will receive
* two props: `navigator` and `navState` representing the navigator
* component and its state. The component is re-rendered when the route
* changes.
*/
navigationBar: PropTypes.node,
/**
* Optionally pass in the navigator object from a parent `Navigator`.
*/
navigator: PropTypes.object,
/**
* Styles to apply to the container of each scene.
*/
sceneStyle: View.propTypes.style,
},
statics: {
BreadcrumbNavigationBar: NavigatorBreadcrumbNavigationBar,
NavigationBar: NavigatorNavigationBar,
SceneConfigs: NavigatorSceneConfigs,
},
mixins: [TimerMixin, InteractionMixin, Subscribable.Mixin],
getDefaultProps: function() {
return {
configureScene: () => NavigatorSceneConfigs.PushFromRight,
sceneStyle: styles.defaultSceneStyle,
};
},
getInitialState: function() {
this._navigationBarNavigator = this.props.navigationBarNavigator || this;
this._renderedSceneMap = new Map();
this._sceneRefs = [];
var routeStack = this.props.initialRouteStack || [this.props.initialRoute];
invariant(
routeStack.length >= 1,
'Navigator requires props.initialRoute or props.initialRouteStack.'
);
var initialRouteIndex = routeStack.length - 1;
if (this.props.initialRoute) {
initialRouteIndex = routeStack.indexOf(this.props.initialRoute);
invariant(
initialRouteIndex !== -1,
'initialRoute is not in initialRouteStack.'
);
}
return {
sceneConfigStack: routeStack.map(
(route) => this.props.configureScene(route, routeStack)
),
routeStack,
presentedIndex: initialRouteIndex,
transitionFromIndex: null,
activeGesture: null,
pendingGestureProgress: null,
transitionQueue: [],
};
},
componentWillMount: function() {
// TODO(t7489503): Don't need this once ES6 Class landed.
this.__defineGetter__('navigationContext', this._getNavigationContext);
this._subRouteFocus = [];
this.parentNavigator = this.props.navigator;
this._handlers = {};
this.springSystem = new rebound.SpringSystem();
this.spring = this.springSystem.createSpring();
this.spring.setRestSpeedThreshold(0.05);
this.spring.setCurrentValue(0).setAtRest();
this.spring.addListener({
onSpringEndStateChange: () => {
if (!this._interactionHandle) {
this._interactionHandle = this.createInteractionHandle();
}
},
onSpringUpdate: () => {
this._handleSpringUpdate();
},
onSpringAtRest: () => {
this._completeTransition();
},
});
this.panGesture = PanResponder.create({
onMoveShouldSetPanResponder: this._handleMoveShouldSetPanResponder,
onPanResponderRelease: this._handlePanResponderRelease,
onPanResponderMove: this._handlePanResponderMove,
onPanResponderTerminate: this._handlePanResponderTerminate,
});
this._interactionHandle = null;
this._emitWillFocus(this.state.routeStack[this.state.presentedIndex]);
},
componentDidMount: function() {
this._handleSpringUpdate();
this._emitDidFocus(this.state.routeStack[this.state.presentedIndex]);
this._enableTVEventHandler();
},
componentWillUnmount: function() {
if (this._navigationContext) {
this._navigationContext.dispose();
this._navigationContext = null;
}
this.spring.destroy();
if (this._interactionHandle) {
this.clearInteractionHandle(this._interactionHandle);
}
this._disableTVEventHandler();
},
/**
* Reset every scene with an array of routes.
*
* @param {RouteStack} nextRouteStack Next route stack to reinitialize.
* All existing route stacks are destroyed and potentially recreated. There
* is no accompanying animation and this method immediately replaces and
* re-renders the navigation bar and the stack items.
*/
immediatelyResetRouteStack: function(nextRouteStack) {
var destIndex = nextRouteStack.length - 1;
this._emitWillFocus(nextRouteStack[destIndex]);
this.setState({
routeStack: nextRouteStack,
sceneConfigStack: nextRouteStack.map(
route => this.props.configureScene(route, nextRouteStack)
),
presentedIndex: destIndex,
activeGesture: null,
transitionFromIndex: null,
transitionQueue: [],
}, () => {
this._handleSpringUpdate();
var navBar = this._navBar;
if (navBar && navBar.immediatelyRefresh) {
navBar.immediatelyRefresh();
}
this._emitDidFocus(this.state.routeStack[this.state.presentedIndex]);
});
},
_transitionTo: function(destIndex, velocity, jumpSpringTo, cb) {
if (this.state.presentedIndex === destIndex) {
cb && cb();
return;
}
if (this.state.transitionFromIndex !== null) {
// Navigation is still transitioning, put the `destIndex` into queue.
this.state.transitionQueue.push({
destIndex,
velocity,
cb,
});
return;
}
this.state.transitionFromIndex = this.state.presentedIndex;
this.state.presentedIndex = destIndex;
this.state.transitionCb = cb;
this._onAnimationStart();
if (AnimationsDebugModule) {
AnimationsDebugModule.startRecordingFps();
}
var sceneConfig = this.state.sceneConfigStack[this.state.transitionFromIndex] ||
this.state.sceneConfigStack[this.state.presentedIndex];
invariant(
sceneConfig,
'Cannot configure scene at index ' + this.state.transitionFromIndex
);
if (jumpSpringTo != null) {
this.spring.setCurrentValue(jumpSpringTo);
}
this.spring.setOvershootClampingEnabled(true);
this.spring.getSpringConfig().friction = sceneConfig.springFriction;
this.spring.getSpringConfig().tension = sceneConfig.springTension;
this.spring.setVelocity(velocity || sceneConfig.defaultTransitionVelocity);
this.spring.setEndValue(1);
},
/**
* This happens for each frame of either a gesture or a transition. If both are
* happening, we only set values for the transition and the gesture will catch up later
*/
_handleSpringUpdate: function() {
if (!this.isMounted()) {
return;
}
// Prioritize handling transition in progress over a gesture:
if (this.state.transitionFromIndex != null) {
this._transitionBetween(
this.state.transitionFromIndex,
this.state.presentedIndex,
this.spring.getCurrentValue()
);
} else if (this.state.activeGesture != null) {
var presentedToIndex = this.state.presentedIndex + this._deltaForGestureAction(this.state.activeGesture);
this._transitionBetween(
this.state.presentedIndex,
presentedToIndex,
this.spring.getCurrentValue()
);
}
},
/**
* This happens at the end of a transition started by transitionTo, and when the spring catches up to a pending gesture
*/
_completeTransition: function() {
if (!this.isMounted()) {
return;
}
if (this.spring.getCurrentValue() !== 1 && this.spring.getCurrentValue() !== 0) {
// The spring has finished catching up to a gesture in progress. Remove the pending progress
// and we will be in a normal activeGesture state
if (this.state.pendingGestureProgress) {
this.state.pendingGestureProgress = null;
}
return;
}
this._onAnimationEnd();
var presentedIndex = this.state.presentedIndex;
var didFocusRoute = this._subRouteFocus[presentedIndex] || this.state.routeStack[presentedIndex];
if (AnimationsDebugModule) {
AnimationsDebugModule.stopRecordingFps(Date.now());
}
this.state.transitionFromIndex = null;
this.spring.setCurrentValue(0).setAtRest();
this._hideScenes();
if (this.state.transitionCb) {
this.state.transitionCb();
this.state.transitionCb = null;
}
this._emitDidFocus(didFocusRoute);
if (this._interactionHandle) {
this.clearInteractionHandle(this._interactionHandle);
this._interactionHandle = null;
}
if (this.state.pendingGestureProgress) {
// A transition completed, but there is already another gesture happening.
// Enable the scene and set the spring to catch up with the new gesture
var gestureToIndex = this.state.presentedIndex + this._deltaForGestureAction(this.state.activeGesture);
this._enableScene(gestureToIndex);
this.spring.setEndValue(this.state.pendingGestureProgress);
return;
}
if (this.state.transitionQueue.length) {
var queuedTransition = this.state.transitionQueue.shift();
this._enableScene(queuedTransition.destIndex);
this._emitWillFocus(this.state.routeStack[queuedTransition.destIndex]);
this._transitionTo(
queuedTransition.destIndex,
queuedTransition.velocity,
null,
queuedTransition.cb
);
}
},
_emitDidFocus: function(route) {
this.navigationContext.emit('didfocus', {route: route});
if (this.props.onDidFocus) {
this.props.onDidFocus(route);
}
},
_emitWillFocus: function(route) {
this.navigationContext.emit('willfocus', {route: route});
var navBar = this._navBar;
if (navBar && navBar.handleWillFocus) {
navBar.handleWillFocus(route);
}
if (this.props.onWillFocus) {
this.props.onWillFocus(route);
}
},
/**
* Hides all scenes that we are not currently on, gesturing to, or transitioning from
*/
_hideScenes: function() {
var gesturingToIndex = null;
if (this.state.activeGesture) {
gesturingToIndex = this.state.presentedIndex + this._deltaForGestureAction(this.state.activeGesture);
}
for (var i = 0; i < this.state.routeStack.length; i++) {
if (i === this.state.presentedIndex ||
i === this.state.transitionFromIndex ||
i === gesturingToIndex) {
continue;
}
this._disableScene(i);
}
},
/**
* Push a scene off the screen, so that opacity:0 scenes will not block touches sent to the presented scenes
*/
_disableScene: function(sceneIndex) {
this._sceneRefs[sceneIndex] &&
this._sceneRefs[sceneIndex].setNativeProps(SCENE_DISABLED_NATIVE_PROPS);
},
/**
* Put the scene back into the state as defined by props.sceneStyle, so transitions can happen normally
*/
_enableScene: function(sceneIndex) {
// First, determine what the defined styles are for scenes in this navigator
var sceneStyle = flattenStyle([styles.baseScene, this.props.sceneStyle]);
// Then restore the pointer events and top value for this scene
var enabledSceneNativeProps = {
pointerEvents: 'auto',
style: {
top: sceneStyle.top,
bottom: sceneStyle.bottom,
},
};
if (sceneIndex !== this.state.transitionFromIndex &&
sceneIndex !== this.state.presentedIndex) {
// If we are not in a transition from this index, make sure opacity is 0
// to prevent the enabled scene from flashing over the presented scene
enabledSceneNativeProps.style.opacity = 0;
}
this._sceneRefs[sceneIndex] &&
this._sceneRefs[sceneIndex].setNativeProps(enabledSceneNativeProps);
},
_clearTransformations: function(sceneIndex) {
const defaultStyle = flattenStyle([styles.defaultSceneStyle]);
this._sceneRefs[sceneIndex].setNativeProps({ style: defaultStyle });
},
_onAnimationStart: function() {
var fromIndex = this.state.presentedIndex;
var toIndex = this.state.presentedIndex;
if (this.state.transitionFromIndex != null) {
fromIndex = this.state.transitionFromIndex;
} else if (this.state.activeGesture) {
toIndex = this.state.presentedIndex + this._deltaForGestureAction(this.state.activeGesture);
}
this._setRenderSceneToHardwareTextureAndroid(fromIndex, true);
this._setRenderSceneToHardwareTextureAndroid(toIndex, true);
var navBar = this._navBar;
if (navBar && navBar.onAnimationStart) {
navBar.onAnimationStart(fromIndex, toIndex);
}
},
_onAnimationEnd: function() {
var max = this.state.routeStack.length - 1;
for (var index = 0; index <= max; index++) {
this._setRenderSceneToHardwareTextureAndroid(index, false);
}
var navBar = this._navBar;
if (navBar && navBar.onAnimationEnd) {
navBar.onAnimationEnd();
}
},
_setRenderSceneToHardwareTextureAndroid: function(sceneIndex, shouldRenderToHardwareTexture) {
var viewAtIndex = this._sceneRefs[sceneIndex];
if (viewAtIndex === null || viewAtIndex === undefined) {
return;
}
viewAtIndex.setNativeProps({renderToHardwareTextureAndroid: shouldRenderToHardwareTexture});
},
_handleTouchStart: function() {
this._eligibleGestures = GESTURE_ACTIONS;
},
_handleMoveShouldSetPanResponder: function(e, gestureState) {
var sceneConfig = this.state.sceneConfigStack[this.state.presentedIndex];
if (!sceneConfig) {
return false;
}
this._expectingGestureGrant =
this._matchGestureAction(this._eligibleGestures, sceneConfig.gestures, gestureState);
return !!this._expectingGestureGrant;
},
_doesGestureOverswipe: function(gestureName) {
var wouldOverswipeBack = this.state.presentedIndex <= 0 &&
(gestureName === 'pop' || gestureName === 'jumpBack');
var wouldOverswipeForward = this.state.presentedIndex >= this.state.routeStack.length - 1 &&
gestureName === 'jumpForward';
return wouldOverswipeForward || wouldOverswipeBack;
},
_deltaForGestureAction: function(gestureAction) {
switch (gestureAction) {
case 'pop':
case 'jumpBack':
return -1;
case 'jumpForward':
return 1;
default:
invariant(false, 'Unsupported gesture action ' + gestureAction);
return;
}
},
_handlePanResponderRelease: function(e, gestureState) {
var sceneConfig = this.state.sceneConfigStack[this.state.presentedIndex];
var releaseGestureAction = this.state.activeGesture;
if (!releaseGestureAction) {
// The gesture may have been detached while responder, so there is no action here
return;
}
var releaseGesture = sceneConfig.gestures[releaseGestureAction];
var destIndex = this.state.presentedIndex + this._deltaForGestureAction(this.state.activeGesture);
if (this.spring.getCurrentValue() === 0) {
// The spring is at zero, so the gesture is already complete
this.spring.setCurrentValue(0).setAtRest();
this._completeTransition();
return;
}
var isTravelVertical = releaseGesture.direction === 'top-to-bottom' || releaseGesture.direction === 'bottom-to-top';
var isTravelInverted = releaseGesture.direction === 'right-to-left' || releaseGesture.direction === 'bottom-to-top';
var velocity, gestureDistance;
if (isTravelVertical) {
velocity = isTravelInverted ? -gestureState.vy : gestureState.vy;
gestureDistance = isTravelInverted ? -gestureState.dy : gestureState.dy;
} else {
velocity = isTravelInverted ? -gestureState.vx : gestureState.vx;
gestureDistance = isTravelInverted ? -gestureState.dx : gestureState.dx;
}
var transitionVelocity = clamp(-10, velocity, 10);
if (Math.abs(velocity) < releaseGesture.notMoving) {
// The gesture velocity is so slow, is "not moving"
var hasGesturedEnoughToComplete = gestureDistance > releaseGesture.fullDistance * releaseGesture.stillCompletionRatio;
transitionVelocity = hasGesturedEnoughToComplete ? releaseGesture.snapVelocity : -releaseGesture.snapVelocity;
}
if (transitionVelocity < 0 || this._doesGestureOverswipe(releaseGestureAction)) {
// This gesture is to an overswiped region or does not have enough velocity to complete
// If we are currently mid-transition, then this gesture was a pending gesture. Because this gesture takes no action, we can stop here
if (this.state.transitionFromIndex == null) {
// There is no current transition, so we need to transition back to the presented index
var transitionBackToPresentedIndex = this.state.presentedIndex;
// slight hack: change the presented index for a moment in order to transitionTo correctly
this.state.presentedIndex = destIndex;
this._transitionTo(
transitionBackToPresentedIndex,
-transitionVelocity,
1 - this.spring.getCurrentValue()
);
}
} else {
// The gesture has enough velocity to complete, so we transition to the gesture's destination
this._emitWillFocus(this.state.routeStack[destIndex]);
this._transitionTo(
destIndex,
transitionVelocity,
null,
() => {
if (releaseGestureAction === 'pop') {
this._cleanScenesPastIndex(destIndex);
}
}
);
}
this._detachGesture();
},
_handlePanResponderTerminate: function(e, gestureState) {
if (this.state.activeGesture == null) {
return;
}
var destIndex = this.state.presentedIndex + this._deltaForGestureAction(this.state.activeGesture);
this._detachGesture();
var transitionBackToPresentedIndex = this.state.presentedIndex;
// slight hack: change the presented index for a moment in order to transitionTo correctly
this.state.presentedIndex = destIndex;
this._transitionTo(
transitionBackToPresentedIndex,
null,
1 - this.spring.getCurrentValue()
);
},
_attachGesture: function(gestureId) {
this.state.activeGesture = gestureId;
var gesturingToIndex = this.state.presentedIndex + this._deltaForGestureAction(this.state.activeGesture);
this._enableScene(gesturingToIndex);
},
_detachGesture: function() {
this.state.activeGesture = null;
this.state.pendingGestureProgress = null;
this._hideScenes();
},
_handlePanResponderMove: function(e, gestureState) {
if (this._isMoveGestureAttached !== undefined) {
invariant(
this._expectingGestureGrant,
'Responder granted unexpectedly.'
);
this._attachGesture(this._expectingGestureGrant);
this._onAnimationStart();
this._expectingGestureGrant = undefined;
}
var sceneConfig = this.state.sceneConfigStack[this.state.presentedIndex];
if (this.state.activeGesture) {
var gesture = sceneConfig.gestures[this.state.activeGesture];
return this._moveAttachedGesture(gesture, gestureState);
}
var matchedGesture = this._matchGestureAction(GESTURE_ACTIONS, sceneConfig.gestures, gestureState);
if (matchedGesture) {
this._attachGesture(matchedGesture);
}
},
_moveAttachedGesture: function(gesture, gestureState) {
var isTravelVertical = gesture.direction === 'top-to-bottom' || gesture.direction === 'bottom-to-top';
var isTravelInverted = gesture.direction === 'right-to-left' || gesture.direction === 'bottom-to-top';
var distance = isTravelVertical ? gestureState.dy : gestureState.dx;
distance = isTravelInverted ? -distance : distance;
var gestureDetectMovement = gesture.gestureDetectMovement;
var nextProgress = (distance - gestureDetectMovement) /
(gesture.fullDistance - gestureDetectMovement);
if (nextProgress < 0 && gesture.isDetachable) {
var gesturingToIndex = this.state.presentedIndex + this._deltaForGestureAction(this.state.activeGesture);
this._transitionBetween(this.state.presentedIndex, gesturingToIndex, 0);
this._detachGesture();
if (this.state.pendingGestureProgress != null) {
this.spring.setCurrentValue(0);
}
return;
}
if (gesture.overswipe && this._doesGestureOverswipe(this.state.activeGesture)) {
var frictionConstant = gesture.overswipe.frictionConstant;
var frictionByDistance = gesture.overswipe.frictionByDistance;
var frictionRatio = 1 / ((frictionConstant) + (Math.abs(nextProgress) * frictionByDistance));
nextProgress *= frictionRatio;
}
nextProgress = clamp(0, nextProgress, 1);
if (this.state.transitionFromIndex != null) {
this.state.pendingGestureProgress = nextProgress;
} else if (this.state.pendingGestureProgress) {
this.spring.setEndValue(nextProgress);
} else {
this.spring.setCurrentValue(nextProgress);
}
},
_matchGestureAction: function(eligibleGestures, gestures, gestureState) {
if (!gestures || !eligibleGestures || !eligibleGestures.some) {
return null;
}
var matchedGesture = null;
eligibleGestures.some((gestureName, gestureIndex) => {
var gesture = gestures[gestureName];
if (!gesture) {
return;
}
if (gesture.overswipe == null && this._doesGestureOverswipe(gestureName)) {
// cannot swipe past first or last scene without overswiping
return false;
}
var isTravelVertical = gesture.direction === 'top-to-bottom' || gesture.direction === 'bottom-to-top';
var isTravelInverted = gesture.direction === 'right-to-left' || gesture.direction === 'bottom-to-top';
var startedLoc = isTravelVertical ? gestureState.y0 : gestureState.x0;
var currentLoc = isTravelVertical ? gestureState.moveY : gestureState.moveX;
var travelDist = isTravelVertical ? gestureState.dy : gestureState.dx;
var oppositeAxisTravelDist =
isTravelVertical ? gestureState.dx : gestureState.dy;
var edgeHitWidth = gesture.edgeHitWidth;
if (isTravelInverted) {
startedLoc = -startedLoc;
currentLoc = -currentLoc;
travelDist = -travelDist;
oppositeAxisTravelDist = -oppositeAxisTravelDist;
edgeHitWidth = isTravelVertical ?
-(SCREEN_HEIGHT - edgeHitWidth) :
-(SCREEN_WIDTH - edgeHitWidth);
}
if (startedLoc === 0) {
startedLoc = currentLoc;
}
var moveStartedInRegion = gesture.edgeHitWidth == null ||
startedLoc < edgeHitWidth;
if (!moveStartedInRegion) {
return false;
}
var moveTravelledFarEnough = travelDist >= gesture.gestureDetectMovement;
if (!moveTravelledFarEnough) {
return false;
}
var directionIsCorrect = Math.abs(travelDist) > Math.abs(oppositeAxisTravelDist) * gesture.directionRatio;
if (directionIsCorrect) {
matchedGesture = gestureName;
return true;
} else {
this._eligibleGestures = this._eligibleGestures.slice().splice(gestureIndex, 1);
}
});
return matchedGesture || null;
},
_transitionSceneStyle: function(fromIndex, toIndex, progress, index) {
var viewAtIndex = this._sceneRefs[index];
if (viewAtIndex === null || viewAtIndex === undefined) {
return;
}
// Use toIndex animation when we move forwards. Use fromIndex when we move back
var sceneConfigIndex = fromIndex < toIndex ? toIndex : fromIndex;
var sceneConfig = this.state.sceneConfigStack[sceneConfigIndex];
// this happens for overswiping when there is no scene at toIndex
if (!sceneConfig) {
sceneConfig = this.state.sceneConfigStack[sceneConfigIndex - 1];
}
var styleToUse = {};
var useFn = index < fromIndex || index < toIndex ?
sceneConfig.animationInterpolators.out :
sceneConfig.animationInterpolators.into;
var directionAdjustedProgress = fromIndex < toIndex ? progress : 1 - progress;
var didChange = useFn(styleToUse, directionAdjustedProgress);
if (didChange) {
viewAtIndex.setNativeProps({style: styleToUse});
}
},
_transitionBetween: function(fromIndex, toIndex, progress) {
this._transitionSceneStyle(fromIndex, toIndex, progress, fromIndex);
this._transitionSceneStyle(fromIndex, toIndex, progress, toIndex);
var navBar = this._navBar;
if (navBar && navBar.updateProgress && toIndex >= 0 && fromIndex >= 0) {
navBar.updateProgress(progress, fromIndex, toIndex);
}
},
_handleResponderTerminationRequest: function() {
return false;
},
_getDestIndexWithinBounds: function(n) {
var currentIndex = this.state.presentedIndex;
var destIndex = currentIndex + n;
invariant(
destIndex >= 0,
'Cannot jump before the first route.'
);
var maxIndex = this.state.routeStack.length - 1;
invariant(
maxIndex >= destIndex,
'Cannot jump past the last route.'
);
return destIndex;
},
_jumpN: function(n) {
var destIndex = this._getDestIndexWithinBounds(n);
this._enableScene(destIndex);
this._emitWillFocus(this.state.routeStack[destIndex]);
this._transitionTo(destIndex);
},
/**
* Transition to an existing scene without unmounting.
* @param {object} route Route to transition to. The specified route must
* be in the currently mounted set of routes defined in `routeStack`.
*/
jumpTo: function(route) {
var destIndex = this.state.routeStack.indexOf(route);
invariant(
destIndex !== -1,
'Cannot jump to route that is not in the route stack'
);
this._jumpN(destIndex - this.state.presentedIndex);
},
/**
* Jump forward to the next scene in the route stack.
*/
jumpForward: function() {
this._jumpN(1);
},
/**
* Jump backward without unmounting the current scene.
*/
jumpBack: function() {
this._jumpN(-1);
},
/**
* Navigate forward to a new scene, squashing any scenes that you could
* jump forward to.
* @param {object} route Route to push into the navigator stack.
*/
push: function(route) {
invariant(!!route, 'Must supply route to push');
var activeLength = this.state.presentedIndex + 1;
var activeStack = this.state.routeStack.slice(0, activeLength);
var activeAnimationConfigStack = this.state.sceneConfigStack.slice(0, activeLength);
var nextStack = activeStack.concat([route]);
var destIndex = nextStack.length - 1;
var nextSceneConfig = this.props.configureScene(route, nextStack);
var nextAnimationConfigStack = activeAnimationConfigStack.concat([nextSceneConfig]);
this._emitWillFocus(nextStack[destIndex]);
this.setState({
routeStack: nextStack,
sceneConfigStack: nextAnimationConfigStack,
}, () => {
this._enableScene(destIndex);
this._transitionTo(destIndex, nextSceneConfig.defaultTransitionVelocity);
});
},
/**
* Go back N scenes at once. When N=1, behavior matches `pop()`.
* When N is invalid(negative or bigger than current routes count), do nothing.
* @param {number} n The number of scenes to pop. Should be an integer.
*/
popN: function(n) {
invariant(typeof n === 'number', 'Must supply a number to popN');
n = parseInt(n, 10);
if (n <= 0 || this.state.presentedIndex - n < 0) {
return;
}
var popIndex = this.state.presentedIndex - n;
var presentedRoute = this.state.routeStack[this.state.presentedIndex];
var popSceneConfig = this.props.configureScene(presentedRoute); // using the scene config of the currently presented view
this._enableScene(popIndex);
// This is needed because scene at the pop index may be transformed
// with a configuration different from the configuration on the presented
// route.
this._clearTransformations(popIndex);
this._emitWillFocus(this.state.routeStack[popIndex]);
this._transitionTo(
popIndex,
popSceneConfig.defaultTransitionVelocity,
null, // no spring jumping
() => {
this._cleanScenesPastIndex(popIndex);
}
);
},
/**
* Transition back and unmount the current scene.
*/
pop: function() {
if (this.state.transitionQueue.length) {
// This is the workaround to prevent user from firing multiple `pop()`
// calls that may pop the routes beyond the limit.
// Because `this.state.presentedIndex` does not update until the
// transition starts, we can't reliably use `this.state.presentedIndex`
// to know whether we can safely keep popping the routes or not at this
// moment.
return;
}
this.popN(1);
},
/**
* Replace a scene as specified by an index.
* @param {object} route Route representing the new scene to render.
* @param {number} index The route in the stack that should be replaced.
* If negative, it counts from the back of the stack.
* @param {Function} cb Callback function when the scene has been replaced.
*/
replaceAtIndex: function(route, index, cb) {
invariant(!!route, 'Must supply route to replace');
if (index < 0) {
index += this.state.routeStack.length;
}
if (this.state.routeStack.length <= index) {
return;
}
var nextRouteStack = this.state.routeStack.slice();
var nextAnimationModeStack = this.state.sceneConfigStack.slice();
nextRouteStack[index] = route;
nextAnimationModeStack[index] = this.props.configureScene(route, nextRouteStack);
if (index === this.state.presentedIndex) {
this._emitWillFocus(route);
}
this.setState({
routeStack: nextRouteStack,
sceneConfigStack: nextAnimationModeStack,
}, () => {
if (index === this.state.presentedIndex) {
this._emitDidFocus(route);
}
cb && cb();
});
},
/**
* Replace the current scene with a new route.
* @param {object} route Route that replaces the current scene.
*/
replace: function(route) {
this.replaceAtIndex(route, this.state.presentedIndex);
},
/**
* Replace the previous scene.
* @param {object} route Route that replaces the previous scene.
*/
replacePrevious: function(route) {
this.replaceAtIndex(route, this.state.presentedIndex - 1);
},
/**
* Pop to the first scene in the stack, unmounting every other scene.
*/
popToTop: function() {
this.popToRoute(this.state.routeStack[0]);
},
/**
* Pop to a particular scene, as specified by its route.
* All scenes after it will be unmounted.
* @param {object} route Route to pop to.
*/
popToRoute: function(route) {
var indexOfRoute = this.state.routeStack.indexOf(route);
invariant(
indexOfRoute !== -1,
'Calling popToRoute for a route that doesn\'t exist!'
);
var numToPop = this.state.presentedIndex - indexOfRoute;
this.popN(numToPop);
},
/**
* Replace the previous scene and pop to it.
* @param {object} route Route that replaces the previous scene.
*/
replacePreviousAndPop: function(route) {
if (this.state.routeStack.length < 2) {
return;
}
this.replacePrevious(route);
this.pop();
},
/**
* Navigate to a new scene and reset route stack.
* @param {object} route Route to navigate to.
*/
resetTo: function(route) {
invariant(!!route, 'Must supply route to push');
this.replaceAtIndex(route, 0, () => {
// Do not use popToRoute here, because race conditions could prevent the
// route from existing at this time. Instead, just go to index 0
this.popN(this.state.presentedIndex);
});
},
/**
* Returns the current list of routes.
*/
getCurrentRoutes: function() {
// Clone before returning to avoid caller mutating the stack
return this.state.routeStack.slice();
},
_cleanScenesPastIndex: function(index) {
var newStackLength = index + 1;
// Remove any unneeded rendered routes.
if (newStackLength < this.state.routeStack.length) {
this.setState({
sceneConfigStack: this.state.sceneConfigStack.slice(0, newStackLength),
routeStack: this.state.routeStack.slice(0, newStackLength),
});
}
},
_renderScene: function(route, i) {
var disabledSceneStyle = null;
var disabledScenePointerEvents = 'auto';
if (i !== this.state.presentedIndex) {
disabledSceneStyle = styles.disabledScene;
disabledScenePointerEvents = 'none';
}
return (
<View
collapsable={false}
key={'scene_' + getRouteID(route)}
ref={(scene) => {
this._sceneRefs[i] = scene;
}}
onStartShouldSetResponderCapture={() => {
return (this.state.transitionFromIndex != null);
}}
pointerEvents={disabledScenePointerEvents}
style={[styles.baseScene, this.props.sceneStyle, disabledSceneStyle]}>
{this.props.renderScene(
route,
this
)}
</View>
);
},
_renderNavigationBar: function() {
const { navigationBar } = this.props;
if (!navigationBar) {
return null;
}
return React.cloneElement(navigationBar, {
ref: (navBar) => {
this._navBar = navBar;
if (navigationBar && typeof navigationBar.ref === 'function') {
navigationBar.ref(navBar);
}
},
navigator: this._navigationBarNavigator,
navState: this.state,
});
},
_tvEventHandler: TVEventHandler,
_enableTVEventHandler: function() {
this._tvEventHandler = new TVEventHandler();
this._tvEventHandler.enable(this, function(cmp, evt) {
if (evt && evt.eventType === 'menu') {
cmp.pop();
}
});
},
_disableTVEventHandler: function() {
if (this._tvEventHandler) {
this._tvEventHandler.disable();
delete this._tvEventHandler;
}
},
render: function() {
var newRenderedSceneMap = new Map();
var scenes = this.state.routeStack.map((route, index) => {
var renderedScene;
if (this._renderedSceneMap.has(route) &&
index !== this.state.presentedIndex) {
renderedScene = this._renderedSceneMap.get(route);
} else {
renderedScene = this._renderScene(route, index);
}
newRenderedSceneMap.set(route, renderedScene);
return renderedScene;
});
this._renderedSceneMap = newRenderedSceneMap;
return (
<View style={[styles.container, this.props.style]}>
<View
style={styles.transitioner}
{...this.panGesture.panHandlers}
onTouchStart={this._handleTouchStart}
onResponderTerminationRequest={
this._handleResponderTerminationRequest
}>
{scenes}
</View>
{this._renderNavigationBar()}
</View>
);
},
_getNavigationContext: function() {
if (!this._navigationContext) {
this._navigationContext = new NavigationContext();
}
return this._navigationContext;
}
});
module.exports = Navigator;