1118 lines
37 KiB
JavaScript
1118 lines
37 KiB
JavaScript
/**
|
|
* Copyright (c) 2015, Facebook, Inc. All rights reserved.
|
|
*
|
|
* 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 Map = require('Map');
|
|
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 StaticContainer = require('StaticContainer.react');
|
|
var StyleSheet = require('StyleSheet');
|
|
var Subscribable = require('Subscribable');
|
|
var TimerMixin = require('react-timer-mixin');
|
|
var View = require('View');
|
|
|
|
var clamp = require('clamp');
|
|
var flattenStyle = require('flattenStyle');
|
|
var invariant = require('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, hwoever 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,
|
|
},
|
|
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',
|
|
];
|
|
|
|
/**
|
|
* Use `Navigator` to transition between different scenes in your app. To
|
|
* accomplish this, provide route objects to the navigator to identify each
|
|
* scene, and also a `renderScene` function that the navigator can use to
|
|
* render the scene for a given route.
|
|
*
|
|
* To change the animation or gesture properties of the scene, provide a
|
|
* `configureScene` prop to get the config object for a given route. See
|
|
* `Navigator.SceneConfigs` for default animations and more info on
|
|
* scene config options.
|
|
*
|
|
* ### Basic Usage
|
|
*
|
|
* ```
|
|
* <Navigator
|
|
* initialRoute={{name: 'My First Scene', index: 0}}
|
|
* renderScene={(route, navigator) =>
|
|
* <MySceneComponent
|
|
* name={route.name}
|
|
* onForward={() => {
|
|
* var nextIndex = route.index + 1;
|
|
* navigator.push({
|
|
* name: 'Scene ' + nextIndex,
|
|
* index: nextIndex,
|
|
* });
|
|
* }}
|
|
* onBack={() => {
|
|
* if (route.index > 0) {
|
|
* navigator.pop();
|
|
* }
|
|
* }}
|
|
* />
|
|
* }
|
|
* />
|
|
* ```
|
|
*
|
|
* ### Navigator Methods
|
|
*
|
|
* If you have a ref to the Navigator element, you can invoke several methods
|
|
* on it to trigger navigation:
|
|
*
|
|
* - `getCurrentRoutes()` - returns the current list of routes
|
|
* - `jumpBack()` - Jump backward without unmounting the current scene
|
|
* - `jumpForward()` - Jump forward to the next scene in the route stack
|
|
* - `jumpTo(route)` - Transition to an existing scene without unmounting
|
|
* - `push(route)` - Navigate forward to a new scene, squashing any scenes
|
|
* that you could `jumpForward` to
|
|
* - `pop()` - Transition back and unmount the current scene
|
|
* - `replace(route)` - Replace the current scene with a new route
|
|
* - `replaceAtIndex(route, index)` - Replace a scene as specified by an index
|
|
* - `replacePrevious(route)` - Replace the previous scene
|
|
* - `immediatelyResetRouteStack(routeStack)` - Reset every scene with an
|
|
* array of routes
|
|
* - `popToRoute(route)` - Pop to a particular scene, as specified by it's
|
|
* route. All scenes after it will be unmounted
|
|
* - `popToTop()` - Pop to the first scene in the stack, unmounting every
|
|
* other scene
|
|
*
|
|
*/
|
|
var Navigator = React.createClass({
|
|
|
|
propTypes: {
|
|
/**
|
|
* Optional function that allows configuration about scene animations and
|
|
* gestures. Will be invoked with the route and should return a scene
|
|
* configuration object
|
|
*
|
|
* ```
|
|
* (route) => Navigator.SceneConfigs.FloatFromRight
|
|
* ```
|
|
*/
|
|
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} />
|
|
* ```
|
|
*/
|
|
renderScene: PropTypes.func.isRequired,
|
|
|
|
/**
|
|
* Specify a route to start on. A route is an object that the navigator
|
|
* will use to identify each scene to render. `initialRoute` must be
|
|
* a route in the `initialRouteStack` if both props are provided. The
|
|
* `initialRoute` will default to the last item in the `initialRouteStack`.
|
|
*/
|
|
initialRoute: PropTypes.object,
|
|
|
|
/**
|
|
* Provide a set of routes to initially mount. Required if no initialRoute
|
|
* is provided. Otherwise, it will default to an array containing only the
|
|
* `initialRoute`
|
|
*/
|
|
initialRouteStack: PropTypes.arrayOf(PropTypes.object),
|
|
|
|
/**
|
|
* @deprecated
|
|
* Use `navigationContext.addListener('willfocus', callback)` instead.
|
|
*
|
|
* Will emit the target route upon mounting and before each nav transition
|
|
*/
|
|
onWillFocus: PropTypes.func,
|
|
|
|
/**
|
|
* @deprecated
|
|
* Use `navigationContext.addListener('didfocus', callback)` instead.
|
|
*
|
|
* Will be called with the new route of each scene after the transition is
|
|
* complete or after the initial mounting
|
|
*/
|
|
onDidFocus: PropTypes.func,
|
|
|
|
/**
|
|
* Optionally provide a navigation bar that persists across scene
|
|
* transitions
|
|
*/
|
|
navigationBar: PropTypes.node,
|
|
|
|
/**
|
|
* Optionally provide 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._renderedSceneMap = new Map();
|
|
|
|
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,
|
|
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,
|
|
onPanResponderGrant: this._handlePanResponderGrant,
|
|
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]);
|
|
},
|
|
|
|
componentWillUnmount: function() {
|
|
if (this._navigationContext) {
|
|
this._navigationContext.dispose();
|
|
this._navigationContext = null;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @param {RouteStack} nextRouteStack Next route stack to reinitialize. This
|
|
* doesn't accept stack item `id`s, which implies that all existing items are
|
|
* destroyed, and then potentially recreated according to `routeStack`. Does
|
|
* not animate, immediately replaces and rerenders navigation bar and stack
|
|
* items.
|
|
*/
|
|
immediatelyResetRouteStack: function(nextRouteStack) {
|
|
var destIndex = nextRouteStack.length - 1;
|
|
this.setState({
|
|
routeStack: nextRouteStack,
|
|
sceneConfigStack: nextRouteStack.map(
|
|
this.props.configureScene
|
|
),
|
|
presentedIndex: destIndex,
|
|
activeGesture: null,
|
|
transitionFromIndex: null,
|
|
transitionQueue: [],
|
|
}, () => {
|
|
this._handleSpringUpdate();
|
|
});
|
|
},
|
|
|
|
_transitionTo: function(destIndex, velocity, jumpSpringTo, cb) {
|
|
if (destIndex === this.state.presentedIndex) {
|
|
return;
|
|
}
|
|
if (this.state.transitionFromIndex !== null) {
|
|
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() {
|
|
// 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.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];
|
|
this._emitDidFocus(didFocusRoute);
|
|
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;
|
|
}
|
|
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.refs['scene_' + sceneIndex] &&
|
|
this.refs['scene_' + 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.refs['scene_' + sceneIndex] &&
|
|
this.refs['scene_' + sceneIndex].setNativeProps(enabledSceneNativeProps);
|
|
},
|
|
|
|
_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._setRenderSceneToHarwareTextureAndroid(fromIndex, true);
|
|
this._setRenderSceneToHarwareTextureAndroid(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._setRenderSceneToHarwareTextureAndroid(index, false);
|
|
}
|
|
|
|
var navBar = this._navBar;
|
|
if (navBar && navBar.onAnimationEnd) {
|
|
navBar.onAnimationEnd();
|
|
}
|
|
},
|
|
|
|
_setRenderSceneToHarwareTextureAndroid: function(sceneIndex, shouldRenderToHardwareTexture) {
|
|
var viewAtIndex = this.refs['scene_' + 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];
|
|
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;
|
|
},
|
|
|
|
_handlePanResponderGrant: function(e, gestureState) {
|
|
invariant(
|
|
this._expectingGestureGrant,
|
|
'Responder granted unexpectedly.'
|
|
);
|
|
this._attachGesture(this._expectingGestureGrant);
|
|
this._onAnimationStart();
|
|
this._expectingGestureGrant = null;
|
|
},
|
|
|
|
_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) {
|
|
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 (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) {
|
|
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 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) {
|
|
currentLoc = -currentLoc;
|
|
travelDist = -travelDist;
|
|
oppositeAxisTravelDist = -oppositeAxisTravelDist;
|
|
edgeHitWidth = isTravelVertical ?
|
|
-(SCREEN_HEIGHT - edgeHitWidth) :
|
|
-(SCREEN_WIDTH - edgeHitWidth);
|
|
}
|
|
var moveStartedInRegion = gesture.edgeHitWidth == null ||
|
|
currentLoc < 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;
|
|
},
|
|
|
|
_transitionSceneStyle: function(fromIndex, toIndex, progress, index) {
|
|
var viewAtIndex = this.refs['scene_' + 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);
|
|
},
|
|
|
|
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);
|
|
},
|
|
|
|
jumpForward: function() {
|
|
this._jumpN(1);
|
|
},
|
|
|
|
jumpBack: function() {
|
|
this._jumpN(-1);
|
|
},
|
|
|
|
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 nextAnimationConfigStack = activeAnimationConfigStack.concat([
|
|
this.props.configureScene(route),
|
|
]);
|
|
this._emitWillFocus(nextStack[destIndex]);
|
|
this.setState({
|
|
routeStack: nextStack,
|
|
sceneConfigStack: nextAnimationConfigStack,
|
|
}, () => {
|
|
this._enableScene(destIndex);
|
|
this._transitionTo(destIndex);
|
|
});
|
|
},
|
|
|
|
_popN: function(n) {
|
|
if (n === 0) {
|
|
return;
|
|
}
|
|
invariant(
|
|
this.state.presentedIndex - n >= 0,
|
|
'Cannot pop below zero'
|
|
);
|
|
var popIndex = this.state.presentedIndex - n;
|
|
this._enableScene(popIndex);
|
|
this._emitWillFocus(this.state.routeStack[popIndex]);
|
|
this._transitionTo(
|
|
popIndex,
|
|
null, // default velocity
|
|
null, // no spring jumping
|
|
() => {
|
|
this._cleanScenesPastIndex(popIndex);
|
|
}
|
|
);
|
|
},
|
|
|
|
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;
|
|
}
|
|
|
|
if (this.state.presentedIndex > 0) {
|
|
this._popN(1);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Replace a route in the navigation stack.
|
|
*
|
|
* `index` specifies the route in the stack that should be replaced.
|
|
* If it's negative, it counts from the back.
|
|
*/
|
|
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);
|
|
|
|
if (index === this.state.presentedIndex) {
|
|
this._emitWillFocus(route);
|
|
}
|
|
this.setState({
|
|
routeStack: nextRouteStack,
|
|
sceneConfigStack: nextAnimationModeStack,
|
|
}, () => {
|
|
if (index === this.state.presentedIndex) {
|
|
this._emitDidFocus(route);
|
|
}
|
|
cb && cb();
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Replaces the current scene in the stack.
|
|
*/
|
|
replace: function(route) {
|
|
this.replaceAtIndex(route, this.state.presentedIndex);
|
|
},
|
|
|
|
/**
|
|
* Replace the current route's parent.
|
|
*/
|
|
replacePrevious: function(route) {
|
|
this.replaceAtIndex(route, this.state.presentedIndex - 1);
|
|
},
|
|
|
|
popToTop: function() {
|
|
this.popToRoute(this.state.routeStack[0]);
|
|
},
|
|
|
|
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);
|
|
},
|
|
|
|
replacePreviousAndPop: function(route) {
|
|
if (this.state.routeStack.length < 2) {
|
|
return;
|
|
}
|
|
this.replacePrevious(route);
|
|
this.pop();
|
|
},
|
|
|
|
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
|
|
if (this.state.presentedIndex > 0) {
|
|
this._popN(this.state.presentedIndex);
|
|
}
|
|
});
|
|
},
|
|
|
|
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
|
|
key={'scene_' + getRouteID(route)}
|
|
ref={'scene_' + i}
|
|
onStartShouldSetResponderCapture={() => {
|
|
return (this.state.transitionFromIndex != null) || (this.state.transitionFromIndex != null);
|
|
}}
|
|
pointerEvents={disabledScenePointerEvents}
|
|
style={[styles.baseScene, this.props.sceneStyle, disabledSceneStyle]}>
|
|
{this.props.renderScene(
|
|
route,
|
|
this
|
|
)}
|
|
</View>
|
|
);
|
|
},
|
|
|
|
_renderNavigationBar: function() {
|
|
if (!this.props.navigationBar) {
|
|
return null;
|
|
}
|
|
return React.cloneElement(this.props.navigationBar, {
|
|
ref: (navBar) => { this._navBar = navBar; },
|
|
navigator: this,
|
|
navState: this.state,
|
|
});
|
|
},
|
|
|
|
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;
|