mirror of
https://github.com/zhigang1992/react-navigation.git
synced 2026-03-06 17:34:59 +08:00
Resolve gesture issues in CardStack
This commit is contained in:
@@ -1,23 +1,19 @@
|
||||
/* @flow */
|
||||
|
||||
import React, { PropTypes, Component } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
NativeModules,
|
||||
Platform,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Animated, StyleSheet, NativeModules, PanResponder, Platform, View, I18nManager, Keyboard } from 'react-native';
|
||||
|
||||
import Transitioner from './Transitioner';
|
||||
import Card from './Card';
|
||||
import CardStackStyleInterpolator from './CardStackStyleInterpolator';
|
||||
import CardStackPanResponder from './CardStackPanResponder';
|
||||
import Header from './Header';
|
||||
import NavigationPropTypes from '../PropTypes';
|
||||
import NavigationActions from '../NavigationActions';
|
||||
import addNavigationHelpers from '../addNavigationHelpers';
|
||||
import SceneView from './SceneView';
|
||||
|
||||
import clamp from 'clamp';
|
||||
|
||||
import type {
|
||||
NavigationAction,
|
||||
NavigationScreenProp,
|
||||
@@ -29,18 +25,19 @@ import type {
|
||||
Style,
|
||||
} from '../TypeDefinition';
|
||||
|
||||
import type {
|
||||
HeaderMode,
|
||||
} from './Header';
|
||||
import type { HeaderMode } from './Header';
|
||||
|
||||
import type { TransitionConfig } from './TransitionConfigs';
|
||||
|
||||
import TransitionConfigs from './TransitionConfigs';
|
||||
|
||||
const NativeAnimatedModule = NativeModules && NativeModules.NativeAnimatedModule;
|
||||
const emptyFunction = () => {};
|
||||
|
||||
const NativeAnimatedModule = NativeModules &&
|
||||
NativeModules.NativeAnimatedModule;
|
||||
|
||||
type Props = {
|
||||
screenProps?: {};
|
||||
screenProps?: {},
|
||||
headerMode: HeaderMode,
|
||||
headerComponent?: ReactClass<*>,
|
||||
mode: 'card' | 'modal',
|
||||
@@ -54,11 +51,7 @@ type Props = {
|
||||
/**
|
||||
* Optional custom animation when transitioning between screens.
|
||||
*/
|
||||
transitionConfig?: (
|
||||
transitionProps: NavigationTransitionProps,
|
||||
prevTransitionProps: NavigationTransitionProps,
|
||||
isModal: boolean,
|
||||
) => TransitionConfig,
|
||||
transitionConfig?: () => TransitionConfig,
|
||||
};
|
||||
|
||||
type DefaultProps = {
|
||||
@@ -66,13 +59,66 @@ type DefaultProps = {
|
||||
headerComponent: ReactClass<*>,
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* The duration of the card animation in milliseconds.
|
||||
*/
|
||||
const ANIMATION_DURATION = 200;
|
||||
|
||||
/**
|
||||
* The gesture distance threshold to trigger the back behavior. For instance,
|
||||
* `1 / 3` means that moving greater than 1 / 3 of the width of the screen will
|
||||
* trigger a back action
|
||||
*/
|
||||
const POSITION_THRESHOLD = 1 / 3;
|
||||
|
||||
/**
|
||||
* The threshold (in pixels) to start the gesture action.
|
||||
*/
|
||||
const RESPOND_THRESHOLD = 12;
|
||||
|
||||
/**
|
||||
* The distance of touch start from the edge of the screen where the gesture will be recognized
|
||||
*/
|
||||
const GESTURE_RESPONSE_DISTANCE = 35;
|
||||
|
||||
|
||||
/**
|
||||
* The ratio between the gesture velocity and the animation velocity. This allows
|
||||
* the velocity of a swipe release to carry on into the new animation.
|
||||
*
|
||||
* TODO: Understand and compute this ratio rather than using an approximation
|
||||
*/
|
||||
const GESTURE_ANIMATED_VELOCITY_RATIO = -4;
|
||||
|
||||
|
||||
class CardStack extends Component<DefaultProps, Props, void> {
|
||||
_render: NavigationSceneRenderer;
|
||||
_renderScene: NavigationSceneRenderer;
|
||||
_childNavigationProps: {
|
||||
[key: string]: NavigationScreenProp<*, NavigationAction>
|
||||
[key: string]: NavigationScreenProp<*, NavigationAction>,
|
||||
} = {};
|
||||
|
||||
/**
|
||||
* Used to identify the starting point of the position when the gesture starts, such that it can
|
||||
* be updated according to its relative position. This means that a card can effectively be
|
||||
* "caught"- If a gesture starts while a card is animating, the card does not jump into a
|
||||
* corresponding location for the touch.
|
||||
*/
|
||||
_gestureStartValue: number = 0;
|
||||
|
||||
// tracks if a touch is currently happening
|
||||
_isResponding: boolean = false;
|
||||
|
||||
/**
|
||||
* immediateIndex is used to represent the expected index that we will be on after a
|
||||
* transition. To achieve a smooth animation when swiping back, the action to go back
|
||||
* doesn't actually fire until the transition completes. The immediateIndex is used during
|
||||
* the transition so that gestures can be handled correctly. This is a work-around for
|
||||
* cases when the user quickly swipes back several times.
|
||||
*/
|
||||
_immediateIndex: ?number = null;
|
||||
|
||||
static Card = Card;
|
||||
static Header = Header;
|
||||
|
||||
@@ -179,15 +225,15 @@ class CardStack extends Component<DefaultProps, Props, void> {
|
||||
).transitionSpec,
|
||||
};
|
||||
if (
|
||||
!!NativeAnimatedModule
|
||||
// Native animation support also depends on the transforms used:
|
||||
&& CardStackStyleInterpolator.canUseNativeDriver(isModal)
|
||||
!!NativeAnimatedModule &&
|
||||
// Native animation support also depends on the transforms used:
|
||||
CardStackStyleInterpolator.canUseNativeDriver(isModal)
|
||||
) {
|
||||
// Internal undocumented prop
|
||||
transitionSpec.useNativeDriver = true;
|
||||
}
|
||||
return transitionSpec;
|
||||
}
|
||||
};
|
||||
|
||||
_renderHeader(
|
||||
transitionProps: NavigationTransitionProps,
|
||||
@@ -206,15 +252,24 @@ class CardStack extends Component<DefaultProps, Props, void> {
|
||||
mode={headerMode}
|
||||
onNavigateBack={() => this.props.navigation.goBack(null)}
|
||||
renderLeftComponent={(props: NavigationTransitionProps) => {
|
||||
const header = this.props.router.getScreenConfig(props.navigation, 'header') || {};
|
||||
const header = this.props.router.getScreenConfig(
|
||||
props.navigation,
|
||||
'header'
|
||||
) || {};
|
||||
return header.left;
|
||||
}}
|
||||
renderRightComponent={(props: NavigationTransitionProps) => {
|
||||
const header = this.props.router.getScreenConfig(props.navigation, 'header') || {};
|
||||
const header = this.props.router.getScreenConfig(
|
||||
props.navigation,
|
||||
'header'
|
||||
) || {};
|
||||
return header.right;
|
||||
}}
|
||||
renderTitleComponent={(props: NavigationTransitionProps) => {
|
||||
const header = this.props.router.getScreenConfig(props.navigation, 'header') || {};
|
||||
const header = this.props.router.getScreenConfig(
|
||||
props.navigation,
|
||||
'header'
|
||||
) || {};
|
||||
// When we return 'undefined' from 'renderXComponent', header treats them as not
|
||||
// specified and default 'renderXComponent' functions are used. In case of 'title',
|
||||
// we return 'undefined' in case of 'string' too because the default 'renderTitle'
|
||||
@@ -228,24 +283,181 @@ class CardStack extends Component<DefaultProps, Props, void> {
|
||||
);
|
||||
}
|
||||
|
||||
_animatedSubscribe(props) {
|
||||
// Hack to make this work with native driven animations. We add a single listener
|
||||
// so the JS value of the following animated values gets updated. We rely on
|
||||
// some Animated private APIs and not doing so would require using a bunch of
|
||||
// value listeners but we'd have to remove them to not leak and I'm not sure
|
||||
// when we'd do that with the current structure we have. `stopAnimation` callback
|
||||
// is also broken with native animated values that have no listeners so if we
|
||||
// want to remove this we have to fix this too.
|
||||
this._animatedSubscribeValue(props.layout.width);
|
||||
this._animatedSubscribeValue(props.layout.height);
|
||||
this._animatedSubscribeValue(props.position);
|
||||
}
|
||||
_animatedSubscribeValue(animatedValue) {
|
||||
if (!animatedValue.__isNative) {
|
||||
return;
|
||||
}
|
||||
if (Object.keys(animatedValue._listeners).length === 0) {
|
||||
animatedValue.addListener(emptyFunction);
|
||||
}
|
||||
}
|
||||
|
||||
_reset(position: Animated.Value, resetToIndex: number, velocity: number): void {
|
||||
Animated.timing(position, {
|
||||
toValue: resetToIndex,
|
||||
duration: ANIMATION_DURATION,
|
||||
useNativeDriver: position.__isNative,
|
||||
velocity: velocity * GESTURE_ANIMATED_VELOCITY_RATIO,
|
||||
bounciness: 0,
|
||||
})
|
||||
.start();
|
||||
}
|
||||
|
||||
_goBack(props: NavigationTransitionProps, velocity: number) {
|
||||
|
||||
const toValue = Math.ceil(props.navigationState.index - 1, 0);
|
||||
|
||||
// set temporary index for gesture handler to respect until the action is
|
||||
// dispatched at the end of the transition.
|
||||
this._immediateIndex = toValue;
|
||||
|
||||
Animated.timing(props.position, {
|
||||
toValue,
|
||||
duration: ANIMATION_DURATION,
|
||||
useNativeDriver: props.position.__isNative,
|
||||
velocity: velocity * GESTURE_ANIMATED_VELOCITY_RATIO,
|
||||
bounciness: 0,
|
||||
})
|
||||
.start(({finished}) => {
|
||||
this._immediateIndex = null;
|
||||
if (!this._isResponding) {
|
||||
this.props.navigation.dispatch(
|
||||
NavigationActions.back({ key: props.scene.route.key })
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_render(props: NavigationTransitionProps): React.Element<*> {
|
||||
let floatingHeader = null;
|
||||
const headerMode = this._getHeaderMode();
|
||||
if (headerMode === 'float') {
|
||||
floatingHeader = this._renderHeader(props, headerMode);
|
||||
}
|
||||
|
||||
const responder = PanResponder.create({
|
||||
onPanResponderTerminate: () => {
|
||||
this._isResponding = false;
|
||||
this._reset(props.position, props.navigation.state.index, 0);
|
||||
},
|
||||
onPanResponderGrant: () => {
|
||||
props.position.stopAnimation((value: number) => {
|
||||
this._isResponding = true;
|
||||
this._gestureStartValue = value;
|
||||
});
|
||||
},
|
||||
onMoveShouldSetPanResponder: (
|
||||
event: { nativeEvent: { pageY: number, pageX: number } },
|
||||
gesture: any
|
||||
) => {
|
||||
if (props.navigationState.index !== props.scene.index) {
|
||||
return false;
|
||||
}
|
||||
const layout = props.layout;
|
||||
const isVertical = false; // todo: bring back gestures for mode=modal
|
||||
const index = props.navigationState.index;
|
||||
const immediateIndex = this._immediateIndex == null ? index : this._immediateIndex;
|
||||
const currentDragDistance = gesture[isVertical ? 'dy' : 'dx'];
|
||||
const currentDragPosition = event.nativeEvent[
|
||||
isVertical ? 'pageY' : 'pageX'
|
||||
];
|
||||
const axisLength = isVertical
|
||||
? layout.height.__getValue()
|
||||
: layout.width.__getValue();
|
||||
const axisHasBeenMeasured = !! axisLength;
|
||||
|
||||
// Measure the distance from the touch to the edge of the screen
|
||||
const screenEdgeDistance = currentDragPosition - currentDragDistance;
|
||||
// GESTURE_RESPONSE_DISTANCE is about 30 or 35
|
||||
if (screenEdgeDistance > GESTURE_RESPONSE_DISTANCE) {
|
||||
// Reject touches that started in the middle of the screen
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasDraggedEnough = Math.abs(currentDragDistance) > RESPOND_THRESHOLD;
|
||||
|
||||
const isOnFirstCard = immediateIndex === 0;
|
||||
const shouldSetResponder = hasDraggedEnough && axisHasBeenMeasured && !isOnFirstCard;
|
||||
return shouldSetResponder;
|
||||
},
|
||||
onPanResponderMove: (event: any, gesture: any) => {
|
||||
// Handle the moving touches for our granted responder
|
||||
const layout = props.layout;
|
||||
const isVertical = false;
|
||||
const startValue = this._gestureStartValue;
|
||||
const axis = isVertical ? 'dy' : 'dx';
|
||||
const index = props.navigationState.index;
|
||||
const distance = isVertical
|
||||
? layout.height.__getValue()
|
||||
: layout.width.__getValue();
|
||||
const currentValue = I18nManager.isRTL && axis === 'dx'
|
||||
? startValue + gesture[axis] / distance
|
||||
: startValue - gesture[axis] / distance;
|
||||
const value = clamp(index - 1, currentValue, index);
|
||||
props.position.setValue(value);
|
||||
},
|
||||
onPanResponderTerminationRequest: (event: any, gesture: any) => {
|
||||
// Returning false will prevent other views from becoming responder while
|
||||
// the navigation view is the responder (mid-gesture)
|
||||
return false;
|
||||
},
|
||||
onPanResponderRelease: (event: any, gesture: any) => {
|
||||
if (!this._isResponding) {
|
||||
return;
|
||||
}
|
||||
this._isResponding = false;
|
||||
const isVertical = false;
|
||||
const axis = isVertical ? 'dy' : 'dx';
|
||||
const velocity = gesture[isVertical ? 'vy' : 'vx'];
|
||||
const index = props.navigationState.index;
|
||||
|
||||
// To asyncronously get the current animated value, we need to run stopAnimation:
|
||||
props.position.stopAnimation((value: number) => {
|
||||
// If the speed of the gesture release is significant, use that as the indication
|
||||
// of intent
|
||||
if (velocity < -0.5) {
|
||||
this._reset(props.position, index, velocity);
|
||||
return;
|
||||
}
|
||||
if (velocity > 0.5) {
|
||||
this._goBack(props, velocity);
|
||||
return;
|
||||
}
|
||||
|
||||
// Then filter based on the distance the screen was moved. Over a third of the way swiped,
|
||||
// and the back will happen.
|
||||
if (value <= index - POSITION_THRESHOLD) {
|
||||
this._goBack(props, velocity);
|
||||
} else {
|
||||
this._reset(props.position, index, velocity);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
const gesturesEnabled = this.props.mode === 'card' && Platform.OS === 'ios';
|
||||
const handlers = gesturesEnabled ? responder.panHandlers : {};
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View
|
||||
style={styles.scenes}
|
||||
>
|
||||
{props.scenes.map(
|
||||
(scene: *) => this._renderScene({
|
||||
...props,
|
||||
scene,
|
||||
navigation: this._getChildNavigation(scene),
|
||||
})
|
||||
)}
|
||||
<View
|
||||
{...handlers}
|
||||
style={styles.container}>
|
||||
<View style={styles.scenes}>
|
||||
{props.scenes.map((scene: any) => this._renderScene({
|
||||
...props,
|
||||
scene,
|
||||
navigation: this._getChildNavigation(scene),
|
||||
}))}
|
||||
</View>
|
||||
{floatingHeader}
|
||||
</View>
|
||||
@@ -268,20 +480,15 @@ class CardStack extends Component<DefaultProps, Props, void> {
|
||||
// props for the old screen
|
||||
prevTransitionProps: NavigationTransitionProps
|
||||
): TransitionConfig {
|
||||
const isModal = this.props.mode === 'modal';
|
||||
const defaultConfig = TransitionConfigs.defaultTransitionConfig(
|
||||
transitionProps,
|
||||
prevTransitionProps,
|
||||
isModal
|
||||
this.props.mode === 'modal'
|
||||
);
|
||||
if (this.props.transitionConfig) {
|
||||
return {
|
||||
...defaultConfig,
|
||||
...this.props.transitionConfig(
|
||||
transitionProps,
|
||||
prevTransitionProps,
|
||||
isModal
|
||||
),
|
||||
...this.props.transitionConfig(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -290,17 +497,21 @@ class CardStack extends Component<DefaultProps, Props, void> {
|
||||
|
||||
_renderInnerCard(
|
||||
SceneComponent: ReactClass<*>,
|
||||
props: NavigationSceneRendererProps,
|
||||
props: NavigationSceneRendererProps
|
||||
): React.Element<*> {
|
||||
const header = this.props.router.getScreenConfig(props.navigation, 'header');
|
||||
const header = this.props.router.getScreenConfig(
|
||||
props.navigation,
|
||||
'header'
|
||||
);
|
||||
const headerMode = this._getHeaderMode();
|
||||
if (headerMode === 'screen') {
|
||||
const isHeaderHidden = header && header.visible === false;
|
||||
const maybeHeader =
|
||||
isHeaderHidden ? null : this._renderHeader(props, headerMode);
|
||||
const maybeHeader = isHeaderHidden
|
||||
? null
|
||||
: this._renderHeader(props, headerMode);
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={{flex: 1}}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<SceneView
|
||||
screenProps={this.props.screenProps}
|
||||
navigation={props.navigation}
|
||||
@@ -325,13 +536,15 @@ class CardStack extends Component<DefaultProps, Props, void> {
|
||||
): NavigationScreenProp<*, NavigationAction> => {
|
||||
let navigation = this._childNavigationProps[scene.key];
|
||||
if (!navigation || navigation.state !== scene.route) {
|
||||
navigation = this._childNavigationProps[scene.key] = addNavigationHelpers({
|
||||
navigation = this._childNavigationProps[
|
||||
scene.key
|
||||
] = addNavigationHelpers({
|
||||
...this.props.navigation,
|
||||
state: scene.route,
|
||||
});
|
||||
}
|
||||
return navigation;
|
||||
}
|
||||
};
|
||||
|
||||
_renderScene(props: NavigationSceneRendererProps): React.Element<*> {
|
||||
const isModal = this.props.mode === 'modal';
|
||||
@@ -347,37 +560,17 @@ class CardStack extends Component<DefaultProps, Props, void> {
|
||||
'cardStack'
|
||||
) || {};
|
||||
|
||||
// On iOS, the default behavior is to allow the user to pop a route by
|
||||
// swiping the corresponding Card away. On Android this is off by default
|
||||
const gesturesEnabledConfig = cardStackConfig.gesturesEnabled;
|
||||
const gesturesEnabled = typeof gesturesEnabledConfig === 'boolean' ?
|
||||
gesturesEnabledConfig :
|
||||
Platform.OS === 'ios';
|
||||
if (gesturesEnabled) {
|
||||
let onNavigateBack = null;
|
||||
if (this.props.navigation.state.index !== 0) {
|
||||
onNavigateBack = () => this.props.navigation.dispatch(
|
||||
NavigationActions.back({ key: props.scene.route.key })
|
||||
);
|
||||
}
|
||||
const panHandlersProps = {
|
||||
...props,
|
||||
onNavigateBack,
|
||||
gestureResponseDistance: this.props.gestureResponseDistance,
|
||||
};
|
||||
panHandlers = isModal ?
|
||||
CardStackPanResponder.forVertical(panHandlersProps) :
|
||||
CardStackPanResponder.forHorizontal(panHandlersProps);
|
||||
}
|
||||
|
||||
const SceneComponent = this.props.router.getComponentForRouteName(props.scene.route.routeName);
|
||||
const SceneComponent = this.props.router.getComponentForRouteName(
|
||||
props.scene.route.routeName
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
{...props}
|
||||
key={`card_${props.scene.key}`}
|
||||
panHandlers={panHandlers}
|
||||
renderScene={(sceneProps: *) => this._renderInnerCard(SceneComponent, sceneProps)}
|
||||
panHandlers={null}
|
||||
renderScene={(sceneProps: *) =>
|
||||
this._renderInnerCard(SceneComponent, sceneProps)}
|
||||
style={[style, this.props.cardStyle]}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user