Use react-native-gesture-handler's PanGestureHandler instead of PanResponder

This commit is contained in:
Brent Vatne
2018-10-10 13:52:38 -07:00
parent 327ca0bbe4
commit f3c83c985d
20 changed files with 2550 additions and 329 deletions

View File

@@ -1,12 +1,20 @@
import React from 'react';
import Expo from 'expo';
import { FlatList } from 'react-native';
import { FlatList, I18nManager } from 'react-native';
import { createSwitchNavigator } from 'react-navigation';
import { createStackNavigator } from 'react-navigation-stack';
import { ListSection, Divider } from 'react-native-paper';
import SimpleStack from './src/SimpleStack';
import ImageStack from './src/ImageStack';
import TransparentStack from './src/TransparentStack';
import ModalStack from './src/ModalStack';
import LifecycleInteraction from './src/LifecycleInteraction';
import GestureInteraction from './src/GestureInteraction';
// Uncomment the following line to force RTL. Requires closing and re-opening
// your app after you first load it with this option enabled.
// I18nManager.forceRTL(true);
// Comment the following two lines to stop using react-native-screens
import { useScreens } from 'react-native-screens';
@@ -14,18 +22,26 @@ useScreens();
const data = [
{ component: SimpleStack, title: 'Simple', routeName: 'SimpleStack' },
{ component: ImageStack, title: 'Image', routeName: 'ImageStack' },
{ component: ModalStack, title: 'Modal', routeName: 'ModalStack' },
{ component: LifecycleInteraction, title: 'Lifecycle', routeName: 'LifecycleStack' },
{
component: TransparentStack,
title: 'Transparent',
routeName: 'TransparentStack',
},
{ component: GestureInteraction, title: 'Gesture Interaction', routeName: 'GestureInteraction' },
];
Expo.Asset.loadAsync(require('react-navigation/src/views/assets/back-icon.png'));
Expo.Asset.loadAsync(require('react-navigation/src/views/assets/back-icon-mask.png'));
class Home extends React.Component {
static navigationOptions = {
title: 'Examples',
};
_renderItem = ({ item }) => (
<ListSection.Item
title={item.title}
@@ -42,6 +58,7 @@ class Home extends React.Component {
renderItem={this._renderItem}
keyExtractor={this._keyExtractor}
data={data}
style={{ backgroundColor: '#fff' }}
/>
);
}

View File

@@ -4,8 +4,10 @@ const path = require('path');
const glob = require('glob-to-regexp');
const blacklist = require('metro/src/blacklist');
const pak = require('../package.json');
const pak2 = require('./package.json');
const dependencies = Object.keys(pak.dependencies);
const localDependencies = Object.keys(pak2.dependencies);
const peerDependencies = Object.keys(pak.peerDependencies);
module.exports = {
@@ -13,7 +15,7 @@ module.exports = {
return [__dirname, path.resolve(__dirname, '..')];
},
getProvidesModuleNodeModules() {
return [...dependencies, ...peerDependencies];
return [...dependencies, ...localDependencies, ...peerDependencies];
},
getBlacklistRE() {
return blacklist([glob(`${path.resolve(__dirname, '..')}/node_modules/*`)]);

View File

@@ -0,0 +1,93 @@
import React from 'react';
import {
ActivityIndicator,
Button,
InteractionManager,
WebView,
View,
StyleSheet,
} from 'react-native';
import { MapView } from 'expo';
import { createStackNavigator, withNavigationFocus } from 'react-navigation';
import { StackGestureContext } from 'react-navigation-stack';
import {
PanGestureHandler,
NativeViewGestureHandler,
} from 'react-native-gesture-handler';
const IndexScreen = ({ navigation }) => (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Button title="Go to MapView" onPress={() => navigation.navigate('Map')} />
<Button title="Go to WebView" onPress={() => navigation.navigate('Web')} />
<Button
title="Return to other examples"
onPress={() => navigation.navigate('Home')}
/>
</View>
);
IndexScreen.navigationOptions = {
title: 'Gesture Interactions',
};
class MapScreen extends React.Component {
constructor(props) {
super(props);
InteractionManager.runAfterInteractions(() => {
this.setState({ interactionComplete: true });
});
this.state = {
interactionComplete: false,
};
}
render() {
if (!this.state.interactionComplete) {
return (
<View
style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}
>
<ActivityIndicator />
</View>
);
}
return (
<StackGestureContext.Consumer>
{ref => (
<NativeViewGestureHandler waitFor={ref}>
<MapView style={{ flex: 1 }} />
</NativeViewGestureHandler>
)}
</StackGestureContext.Consumer>
);
}
}
MapScreen.navigationOptions = {
title: 'MapView',
};
const WebViewScreen = () => (
<StackGestureContext.Consumer>
{ref => (
<NativeViewGestureHandler waitFor={ref}>
<WebView
style={{ flex: 1 }}
source={{ uri: 'https://news.google.com' }}
/>
</NativeViewGestureHandler>
)}
</StackGestureContext.Consumer>
);
WebViewScreen.navigationOptions = {
title: 'WebView',
};
export default createStackNavigator({
Index: IndexScreen,
Map: MapScreen,
Web: WebViewScreen,
});

View File

@@ -0,0 +1,89 @@
import React from 'react';
import { Dimensions, Button, Image, View, Text } from 'react-native';
import { createStackNavigator } from 'react-navigation-stack';
import { FlatList, BorderlessButton } from 'react-native-gesture-handler';
class ListScreen extends React.Component {
static navigationOptions = ({ navigation }) => ({
title: 'Image list',
headerBackTitle: 'Back',
headerLeft: (
<Button title="Back" onPress={() => navigation.navigate('Home')} />
),
});
state = {
items: Array.apply(null, Array(60)).map((v, i) => {
return {
id: i,
src: `https://source.unsplash.com/random/400x${400 + i}`,
};
}),
};
render() {
return (
<FlatList
data={this.state.items}
renderItem={({ item }) => (
<View style={{ flex: 1, flexDirection: 'column', margin: 1 }}>
<BorderlessButton
onPress={() =>
this.props.navigation.navigate('Details', {
id: item.id,
src: item.src,
})
}
>
<Image style={{ height: 100 }} source={{ uri: item.src }} />
</BorderlessButton>
</View>
)}
numColumns={3}
keyExtractor={(item, index) => index}
style={{ flex: 1, backgroundColor: '#fff' }}
/>
);
}
}
class DetailsScreen extends React.Component {
static navigationOptions = {
title: 'Random image from Unsplash',
};
render() {
let id = this.props.navigation.getParam('id', 0);
return (
<View
style={{
flex: 1,
backgroundColor: '#fff',
}}
>
<Image
source={{
uri: `https://source.unsplash.com/random/1080x${1920 + id}`,
}}
style={{ width: Dimensions.get('window').width, height: 400 }}
resizeMode="cover"
/>
<Button
title="Go back"
onPress={() => this.props.navigation.goBack()}
/>
</View>
);
}
}
export default createStackNavigator(
{
List: ListScreen,
Details: DetailsScreen,
},
{
initialRouteName: 'List',
}
);

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { ActivityIndicator, Button, Text, View, StyleSheet } from 'react-native';
import { BarCodeScanner } from 'expo';
import { createStackNavigator, withNavigationFocus } from 'react-navigation';
const IndexScreen = ({ navigation }) => (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Button
title="Go to BarCodeScanner"
onPress={() => navigation.navigate('BarCode')}
/>
<Button
title="Return to other examples"
onPress={() => navigation.navigate('Home')}
/>
</View>
);
IndexScreen.navigationOptions = {
title: 'Lifecycle Interactions',
};
@withNavigationFocus
class BarCodeScreen extends React.Component {
handleBarCodeScanned = data => {
console.log('scanned...');
this.props.navigation.navigate('Info', { data });
};
render() {
return (
<View style={{ flex: 1 }}>
<BarCodeScanner
onBarCodeScanned={this.props.isFocused ? this.handleBarCodeScanned : null}
style={StyleSheet.absoluteFill}
/>
</View>
);
}
}
BarCodeScreen.navigationOptions = {
title: 'BarCodeView',
};
class InfoScreen extends React.Component {
render() {
return (
<View style={{ flex: 1 }}>
<Text>{JSON.stringify(this.props.navigation.getParam('data'))}</Text>
</View>
);
}
}
InfoScreen.navigationOptions = {
title: 'Info',
};
export default createStackNavigator(
{
Index: IndexScreen,
BarCode: BarCodeScreen,
Info: InfoScreen,
},
{
initialRouteName: 'Index',
}
);

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { Button, View, Text } from 'react-native';
import { createStackNavigator } from 'react-navigation-stack';
class ListScreen extends React.Component {
render() {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text>List Screen</Text>
<Text>A list may go here</Text>
<Button
title="Go to Details"
onPress={() => this.props.navigation.navigate('Details')}
/>
<Button
title="Go back to all examples"
onPress={() => this.props.navigation.navigate('Home')}
/>
</View>
);
}
}
class DetailsScreen extends React.Component {
render() {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text>Details Screen</Text>
<Button
title="Go to Details... again"
onPress={() => this.props.navigation.push('Details')}
/>
<Button
title="Go to List"
onPress={() => this.props.navigation.navigate('List')}
/>
<Button
title="Go back"
onPress={() => this.props.navigation.goBack()}
/>
<Button
title="Go back to all examples"
onPress={() => this.props.navigation.navigate('Home')}
/>
</View>
);
}
}
export default createStackNavigator(
{
List: ListScreen,
Details: DetailsScreen,
},
{
initialRouteName: 'List',
mode: 'modal',
}
);

View File

@@ -5,7 +5,14 @@ import { createStackNavigator } from 'react-navigation-stack';
class ListScreen extends React.Component {
render() {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#fff',
}}
>
<Text>List Screen</Text>
<Text>A list may go here</Text>
<Button
@@ -24,7 +31,14 @@ class ListScreen extends React.Component {
class DetailsScreen extends React.Component {
render() {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#fff',
}}
>
<Text>Details Screen</Text>
<Button
title="Go to Details... again"

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "react-navigation-stack",
"version": "0.6.0",
"version": "1.0.0-alpha.13",
"description": "Stack navigator component for React Navigation",
"main": "dist/index.js",
"files": [
@@ -14,7 +14,6 @@
"test": "jest",
"lint": "eslint .",
"format": "eslint . --fix",
"precommit": "yarn lint && yarn test",
"build": "babel --no-babelrc --plugins=syntax-jsx,syntax-class-properties,syntax-object-rest-spread,transform-flow-strip-types src --copy-files --out-dir dist --ignore '**/__tests__/**'",
"prepare": "yarn build"
},
@@ -62,6 +61,8 @@
"react": "*",
"react-native": "*",
"react-native-screens": "^1.0.0 || ^1.0.0-alpha",
"react-native-gesture-handler": "^1.0.0",
"react-native-reanimated": "^1.0.0 || ^1.0.0-alpha",
"react-navigation": ">=2.0 || ^2.0.0-beta"
},
"jest": {

View File

@@ -52,4 +52,7 @@ module.exports = {
get ScenesReducer() {
return require('./views/ScenesReducer').default;
},
get StackGestureContext() {
return require('./utils/StackGestureContext').default;
}
};

View File

@@ -0,0 +1,3 @@
import React from 'react';
export default React.createContext(null);

View File

@@ -523,7 +523,6 @@ class Header extends React.PureComponent {
});
const scenesProps = Object.values(scenesByIndex).map(scene => ({
position: this.props.position,
progress: this.props.progress,
scene,
}));
appBar = scenesProps.map(this._renderHeader, this);
@@ -531,7 +530,6 @@ class Header extends React.PureComponent {
} else {
const headerProps = {
position: new Animated.Value(this.props.scene.index),
progress: new Animated.Value(0),
scene: this.props.scene,
};

View File

@@ -33,7 +33,8 @@ class HeaderBackButton extends React.PureComponent {
};
_renderBackImage() {
const { backImage, title, tintColor } = this.props;
const { backImage, backTitleVisible, tintColor } = this.props;
let title = this._getTitleText();
let BackImage;
let props;
@@ -51,7 +52,7 @@ class HeaderBackButton extends React.PureComponent {
props = {
style: [
styles.icon,
!!title && styles.iconWithTitle,
!!backTitleVisible && styles.iconWithTitle,
!!tintColor && { tintColor },
],
source: defaultBackImage,
@@ -61,33 +62,33 @@ class HeaderBackButton extends React.PureComponent {
return <BackImage {...props} />;
}
_getTitleText = () => {
const { width, title, truncatedTitle } = this.props;
let { initialTextWidth } = this.state;
if (title === null) {
return null;
} else if (!title) {
return truncatedTitle;
} else if (initialTextWidth && width && initialTextWidth > width) {
return truncatedTitle;
} else {
return title;
}
};
_maybeRenderTitle() {
const {
layoutPreset,
backTitleVisible,
width,
title,
titleStyle,
tintColor,
truncatedTitle,
} = this.props;
const renderTruncated =
this.state.initialTextWidth && width
? this.state.initialTextWidth > width
: false;
let backTitleText = this._getTitleText();
const backButtonTitle = renderTruncated ? truncatedTitle : title;
// If the left preset is used and we aren't on Android, then we
// default to disabling the label
const titleDefaultsToDisabled =
layoutPreset === 'left' ||
Platform.OS === 'android' ||
typeof backButtonTitle !== 'string';
// If the title is explicitly enabled then we respect that
if (titleDefaultsToDisabled && !backTitleVisible) {
if (!backTitleVisible || backTitleText === null) {
return null;
}
@@ -98,7 +99,7 @@ class HeaderBackButton extends React.PureComponent {
style={[styles.title, !!tintColor && { color: tintColor }, titleStyle]}
numberOfLines={1}
>
{backButtonTitle}
{this._getTitleText()}
</Text>
);
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { StyleSheet, Platform } from 'react-native';
import { Animated, StyleSheet, Platform } from 'react-native';
import { Screen } from 'react-native-screens';
import createPointerEventsContainer from './createPointerEventsContainer';
@@ -25,6 +25,7 @@ function getAccessibilityProps(isActive) {
class Card extends React.Component {
render() {
const {
animatedStyle,
children,
pointerEvents,
style,
@@ -42,28 +43,61 @@ class Card extends React.Component {
extrapolate: 'clamp',
});
const {
shadowOpacity,
overlayOpacity,
...containerAnimatedStyle
} = animatedStyle;
return (
<Screen
pointerEvents={pointerEvents}
onComponentRef={this.props.onComponentRef}
style={[transparent ? styles.transparent : styles.main, style]}
style={[StyleSheet.absoluteFill, containerAnimatedStyle, style]}
active={active}
{...getAccessibilityProps(isActive)}
>
{children}
{shadowOpacity ? (
<Animated.View
style={[styles.shadow, { shadowOpacity }]}
pointerEvents="none"
/>
) : null}
<Animated.View
{...getAccessibilityProps(isActive)}
style={[transparent ? styles.transparent : styles.card]}
>
{children}
</Animated.View>
{overlayOpacity ? (
<Animated.View
pointerEvents="none"
style={[styles.overlay, { opacity: overlayOpacity }]}
/>
) : null}
</Screen>
);
}
}
const styles = StyleSheet.create({
main: {
card: {
flex: 1,
backgroundColor: '#fff',
},
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#E9E9EF',
shadowColor: 'black',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.2,
backgroundColor: '#000',
},
shadow: {
top: 0,
left: 0,
bottom: 0,
width: 3,
position: 'absolute',
backgroundColor: '#fff',
shadowOffset: { width: -1, height: 1 },
shadowRadius: 5,
shadowColor: '#000',
},
transparent: {
...StyleSheet.absoluteFillObject,

View File

@@ -1,6 +1,5 @@
import React from 'react';
import clamp from '../../utils/clamp';
import {
Animated,
StyleSheet,
@@ -19,11 +18,13 @@ import {
NavigationProvider,
} from 'react-navigation';
import { ScreenContainer } from 'react-native-screens';
import { PanGestureHandler, State } from 'react-native-gesture-handler';
import Card from './StackViewCard';
import Header from '../Header/Header';
import TransitionConfigs from './StackViewTransitionConfigs';
import StackGestureContext from '../../utils/StackGestureContext';
import clamp from '../../utils/clamp';
import { supportsImprovedSpringAnimation } from '../../utils/ReactNativeFeatures';
const emptyFunction = () => {};
@@ -70,17 +71,10 @@ const RESPOND_THRESHOLD = 20;
/**
* The distance of touch start from the edge of the screen where the gesture will be recognized
*/
const GESTURE_RESPONSE_DISTANCE_HORIZONTAL = 25;
const GESTURE_RESPONSE_DISTANCE_HORIZONTAL = 50;
const GESTURE_RESPONSE_DISTANCE_VERTICAL = 135;
const animatedSubscribeValue = animatedValue => {
if (!animatedValue.__isNative) {
return;
}
if (Object.keys(animatedValue._listeners).length === 0) {
animatedValue.addListener(emptyFunction);
}
};
const USE_NATIVE_DRIVER = true;
const getDefaultHeaderHeight = isLandscape => {
if (Platform.OS === 'ios') {
@@ -119,6 +113,9 @@ class StackViewLayout extends React.Component {
constructor(props) {
super(props);
this.panGestureRef = React.createRef();
this.gestureX = new Animated.Value(0);
this.gestureY = new Animated.Value(0);
this.state = {
// Used when card's header is null and mode is float to make transition
@@ -127,6 +124,7 @@ class StackViewLayout extends React.Component {
// on mount what the header height is so we have just used the most
// common cases here.
floatingHeaderHeight: getDefaultHeaderHeight(props.isLandscape),
gesturePosition: null,
};
}
@@ -166,11 +164,12 @@ class StackViewLayout extends React.Component {
{renderHeader({
...passProps,
...transitionProps,
position: this._getPosition(),
scene,
mode: headerMode,
transitionPreset: this._getHeaderTransitionPreset(),
layoutPreset: this._getHeaderLayoutPreset(),
backTitleVisible: this._getheaderBackTitleVisible(),
backTitleVisible: this._getHeaderBackTitleVisible(),
leftInterpolator: headerLeftInterpolator,
titleInterpolator: headerTitleInterpolator,
rightInterpolator: headerRightInterpolator,
@@ -180,19 +179,6 @@ class StackViewLayout extends React.Component {
);
}
_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.
animatedSubscribeValue(props.transitionProps.layout.width);
animatedSubscribeValue(props.transitionProps.layout.height);
animatedSubscribeValue(props.transitionProps.position);
}
_reset(resetToIndex, duration) {
if (Platform.OS === 'ios' && supportsImprovedSpringAnimation()) {
Animated.spring(this.props.transitionProps.position, {
@@ -200,14 +186,14 @@ class StackViewLayout extends React.Component {
stiffness: 5000,
damping: 600,
mass: 3,
useNativeDriver: this.props.transitionProps.position.__isNative,
useNativeDriver: USE_NATIVE_DRIVER,
}).start();
} else {
Animated.timing(this.props.transitionProps.position, {
toValue: resetToIndex,
duration,
easing: EaseInOut,
useNativeDriver: this.props.transitionProps.position.__isNative,
useNativeDriver: USE_NATIVE_DRIVER,
}).start();
}
}
@@ -237,203 +223,21 @@ class StackViewLayout extends React.Component {
if (Platform.OS === 'ios' && supportsImprovedSpringAnimation()) {
Animated.spring(position, {
toValue,
stiffness: 5000,
stiffness: 7000,
damping: 600,
mass: 3,
useNativeDriver: position.__isNative,
useNativeDriver: USE_NATIVE_DRIVER,
}).start(onCompleteAnimation);
} else {
Animated.timing(position, {
toValue,
duration,
easing: EaseInOut,
useNativeDriver: position.__isNative,
useNativeDriver: USE_NATIVE_DRIVER,
}).start(onCompleteAnimation);
}
}
_panResponder = PanResponder.create({
onPanResponderTerminate: () => {
const { navigation } = this.props.transitionProps;
const { index } = navigation.state;
this._isResponding = false;
this._reset(index, 0);
this.props.onGestureCanceled && this.props.onGestureCanceled();
},
onPanResponderGrant: () => {
const {
transitionProps: { navigation, position, scene },
} = this.props;
const { index } = navigation.state;
if (index !== scene.index) {
return false;
}
position.stopAnimation(value => {
this._isResponding = true;
this._gestureStartValue = value;
});
this.props.onGestureBegin && this.props.onGestureBegin();
},
onMoveShouldSetPanResponder: (event, gesture) => {
const {
transitionProps: { navigation, layout, scene },
mode,
} = this.props;
const { index } = navigation.state;
const isVertical = mode === 'modal';
const { options } = scene.descriptor;
const gestureDirection = options.gestureDirection;
const gestureDirectionInverted =
typeof gestureDirection === 'string'
? gestureDirection === 'inverted'
: I18nManager.isRTL;
if (index !== scene.index) {
return false;
}
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 = gestureDirectionInverted
? axisLength - (currentDragPosition - currentDragDistance)
: currentDragPosition - currentDragDistance;
// Compare to the gesture distance relavant to card or modal
const {
gestureResponseDistance: userGestureResponseDistance = {},
} = options;
const gestureResponseDistance = isVertical
? userGestureResponseDistance.vertical ||
GESTURE_RESPONSE_DISTANCE_VERTICAL
: userGestureResponseDistance.horizontal ||
GESTURE_RESPONSE_DISTANCE_HORIZONTAL;
// GESTURE_RESPONSE_DISTANCE is about 25 or 30. Or 135 for modals
if (screenEdgeDistance > gestureResponseDistance) {
// 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, gesture) => {
const {
transitionProps: { navigation, position, layout, scene },
mode,
} = this.props;
const { index } = navigation.state;
const isVertical = mode === 'modal';
const { options } = scene.descriptor;
const gestureDirection = options.gestureDirection;
const gestureDirectionInverted =
typeof gestureDirection === 'string'
? gestureDirection === 'inverted'
: I18nManager.isRTL;
// Handle the moving touches for our granted responder
const startValue = this._gestureStartValue;
const axis = isVertical ? 'dy' : 'dx';
const axisDistance = isVertical
? layout.height.__getValue()
: layout.width.__getValue();
const currentValue =
axis === 'dx' && gestureDirectionInverted
? startValue + gesture[axis] / axisDistance
: startValue - gesture[axis] / axisDistance;
const value = clamp(index - 1, currentValue, index);
position.setValue(value);
},
onPanResponderTerminationRequest: () =>
// Returning false will prevent other views from becoming responder while
// the navigation view is the responder (mid-gesture)
false,
onPanResponderRelease: (event, gesture) => {
const {
transitionProps: { navigation, position, layout, scene },
mode,
} = this.props;
const { index } = navigation.state;
const isVertical = mode === 'modal';
const { options } = scene.descriptor;
const gestureDirection = options.gestureDirection;
const gestureDirectionInverted =
typeof gestureDirection === 'string'
? gestureDirection === 'inverted'
: I18nManager.isRTL;
if (!this._isResponding) {
return;
}
this._isResponding = false;
const immediateIndex =
this._immediateIndex == null ? index : this._immediateIndex;
// Calculate animate duration according to gesture speed and moved distance
const axisDistance = isVertical
? layout.height.__getValue()
: layout.width.__getValue();
const movementDirection = gestureDirectionInverted ? -1 : 1;
const movedDistance =
movementDirection * gesture[isVertical ? 'dy' : 'dx'];
const gestureVelocity =
movementDirection * gesture[isVertical ? 'vy' : 'vx'];
const defaultVelocity = axisDistance / ANIMATION_DURATION;
const velocity = Math.max(Math.abs(gestureVelocity), defaultVelocity);
const resetDuration = gestureDirectionInverted
? (axisDistance - movedDistance) / velocity
: movedDistance / velocity;
const goBackDuration = gestureDirectionInverted
? movedDistance / velocity
: (axisDistance - movedDistance) / velocity;
// To asyncronously get the current animated value, we need to run stopAnimation:
position.stopAnimation(value => {
// If the speed of the gesture release is significant, use that as the indication
// of intent
if (gestureVelocity < -0.5) {
this.props.onGestureCanceled && this.props.onGestureCanceled();
this._reset(immediateIndex, resetDuration);
return;
}
if (gestureVelocity > 0.5) {
this.props.onGestureFinish && this.props.onGestureFinish();
this._goBack(immediateIndex, goBackDuration);
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.props.onGestureFinish && this.props.onGestureFinish();
this._goBack(immediateIndex, goBackDuration);
} else {
this.props.onGestureCanceled && this.props.onGestureCanceled();
this._reset(immediateIndex, resetDuration);
}
});
},
});
_onFloatingHeaderLayout = e => {
this.setState({ floatingHeaderHeight: e.nativeEvent.layout.height });
};
@@ -455,33 +259,316 @@ class StackViewLayout extends React.Component {
);
}
const {
transitionProps: { scene, scenes },
transitionProps: { navigation, scene, scenes },
} = this.props;
const { options } = scene.descriptor;
const { index } = navigation.state;
const gesturesEnabled =
typeof options.gesturesEnabled === 'boolean'
? options.gesturesEnabled
: Platform.OS === 'ios';
const responder = !gesturesEnabled ? null : this._panResponder;
const handlers = gesturesEnabled ? responder.panHandlers : {};
const containerStyle = [
styles.container,
this._getTransitionConfig().containerStyle,
];
return (
<View {...handlers} style={containerStyle}>
<ScreenContainer style={styles.scenes}>
{scenes.map(s => this._renderCard(s))}
</ScreenContainer>
{floatingHeader}
</View>
<PanGestureHandler
{...this._gestureActivationCriteria()}
ref={this.panGestureRef}
onGestureEvent={Animated.event(
[
{
nativeEvent: {
translationX: this.gestureX,
translationY: this.gestureY,
},
},
],
{
useNativeDriver: USE_NATIVE_DRIVER,
}
)}
onHandlerStateChange={this._handlePanGestureStateChange}
enabled={index > 0 && gesturesEnabled}
>
<Animated.View style={containerStyle}>
<StackGestureContext.Provider value={this.panGestureRef}>
<ScreenContainer style={styles.scenes}>
{scenes.map(s => this._renderCard(s))}
</ScreenContainer>
{floatingHeader}
</StackGestureContext.Provider>
</Animated.View>
</PanGestureHandler>
);
}
_gestureActivationCriteria = () => {
let { layout } = this.props.transitionProps;
if (this._isMotionVertical()) {
let height = layout.height.__getValue();
return {
maxDeltaX: 15,
minOffsetY: 5,
hitSlop: { bottom: -height + GESTURE_RESPONSE_DISTANCE_VERTICAL },
};
} else {
let width = layout.width.__getValue();
let hitSlop = -width + GESTURE_RESPONSE_DISTANCE_HORIZONTAL;
return {
minOffsetX: this._isMotionInverted() ? -5 : 5,
maxDeltaY: 20,
hitSlop: this._isMotionInverted()
? { left: hitSlop }
: { right: hitSlop },
};
}
};
// Without using Reanimated it's not possible to do all of the following
// stuff with native driver.
_handlePanGestureEvent = ({ nativeEvent }) => {
if (this._isMotionVertical()) {
this._handleVerticalPan(nativeEvent);
} else {
this._handleHorizontalPan(nativeEvent);
}
};
_isMotionVertical = () => {
return this.props.mode === 'modal';
};
// This only currently applies to the horizontal gesture!
_isMotionInverted = () => {
const {
transitionProps: { scene },
} = this.props;
const { options } = scene.descriptor;
const { gestureDirection } = options;
return typeof gestureDirection === 'string'
? gestureDirection === 'inverted'
: I18nManager.isRTL;
};
_handleHorizontalPan = nativeEvent => {
let value = this._computeHorizontalGestureValue(nativeEvent);
this.props.transitionProps.position.setValue(Math.max(0, value));
};
_computeHorizontalGestureValue = nativeEvent => {
let {
transitionProps: { navigation, layout },
} = this.props;
let { index } = navigation.state;
// TODO: remove this __getValue!
let distance = layout.width.__getValue();
let translationX = this._isMotionInverted()
? -1 * nativeEvent.translationX
: nativeEvent.translationX;
let value = index - translationX / distance;
return clamp(index - 1, value, index);
};
_computeVerticalGestureValue = nativeEvent => {
let {
transitionProps: { navigation, layout },
} = this.props;
let { index } = navigation.state;
// TODO: remove this __getValue!
let distance = layout.height.__getValue();
let translationY = nativeEvent.translationY;
let value = index - nativeEvent.translationY / distance;
return clamp(index - 1, value, index);
};
_handlePanGestureStateChange = ({ nativeEvent }) => {
if (nativeEvent.oldState === State.ACTIVE) {
if (this._isMotionVertical()) {
this._handleReleaseVertical(nativeEvent);
} else {
this._handleReleaseHorizontal(nativeEvent);
}
} else if (nativeEvent.state === State.ACTIVE) {
if (this._isMotionVertical()) {
this._handleActivateGestureVertical(nativeEvent);
} else {
this._handleActivateGestureHorizontal(nativeEvent);
}
}
};
_handleActivateGestureHorizontal = nativeEvent => {
let { index } = this.props.transitionProps.navigation.state;
if (this._isMotionInverted()) {
this.setState({
gesturePosition: Animated.add(
index,
Animated.divide(
this.gestureX,
this.props.transitionProps.layout.width
)
).interpolate({
inputRange: [index - 1, index],
outputRange: [index - 1, index],
extrapolate: 'clamp',
}),
});
} else {
this.setState({
gesturePosition: Animated.add(
index,
Animated.multiply(
-1,
Animated.divide(
this.gestureX,
this.props.transitionProps.layout.width
)
)
).interpolate({
inputRange: [index - 1, index],
outputRange: [index - 1, index],
extrapolate: 'clamp',
}),
});
}
};
_handleActivateGestureVertical = nativeEvent => {
let { index } = this.props.transitionProps.navigation.state;
this.setState({
gesturePosition: Animated.add(
index,
Animated.multiply(
-1,
Animated.divide(
this.gestureY,
this.props.transitionProps.layout.height
)
)
).interpolate({
inputRange: [index - 1, index],
outputRange: [index - 1, index],
extrapolate: 'clamp',
}),
});
};
_handleReleaseHorizontal = nativeEvent => {
const {
transitionProps: { navigation, position, layout, scene },
mode,
} = this.props;
const { index } = navigation.state;
const immediateIndex =
this._immediateIndex == null ? index : this._immediateIndex;
// Calculate animate duration according to gesture speed and moved distance
const distance = layout.width.__getValue();
const movementDirection = this._isMotionInverted() ? -1 : 1;
const movedDistance = movementDirection * nativeEvent.translationX;
const gestureVelocity = movementDirection * nativeEvent.velocityX;
const defaultVelocity = distance / ANIMATION_DURATION;
const velocity = Math.max(Math.abs(gestureVelocity), defaultVelocity);
const resetDuration = this._isMotionInverted()
? (distance - movedDistance) / velocity
: movedDistance / velocity;
const goBackDuration = this._isMotionInverted()
? movedDistance / velocity
: (distance - movedDistance) / velocity;
// Get the current position value and reset to using the statically driven
// (rather than gesture driven) position.
let value = this._computeHorizontalGestureValue(nativeEvent);
position.setValue(value);
this.setState({ gesturePosition: null }, () => {
// If the speed of the gesture release is significant, use that as the indication
// of intent
if (gestureVelocity < -50) {
this.props.onGestureCanceled && this.props.onGestureCanceled();
this._reset(immediateIndex, resetDuration);
return;
}
if (gestureVelocity > 50) {
this.props.onGestureFinish && this.props.onGestureFinish();
this._goBack(immediateIndex, goBackDuration);
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.props.onGestureFinish && this.props.onGestureFinish();
this._goBack(immediateIndex, goBackDuration);
} else {
this.props.onGestureCanceled && this.props.onGestureCanceled();
this._reset(immediateIndex, resetDuration);
}
});
};
_handleReleaseVertical = nativeEvent => {
const {
transitionProps: { navigation, position, layout, scene },
mode,
} = this.props;
const { index } = navigation.state;
const immediateIndex =
this._immediateIndex == null ? index : this._immediateIndex;
// Calculate animate duration according to gesture speed and moved distance
const distance = layout.height.__getValue();
const movedDistance = nativeEvent.translationY;
const gestureVelocity = nativeEvent.velocityY;
const defaultVelocity = distance / ANIMATION_DURATION;
const velocity = Math.max(Math.abs(gestureVelocity), defaultVelocity);
const resetDuration = movedDistance / velocity;
const goBackDuration = (distance - movedDistance) / velocity;
let value = this._computeVerticalGestureValue(nativeEvent);
position.setValue(value);
this.setState({ gesturePosition: null }, () => {
// If the speed of the gesture release is significant, use that as the indication
// of intent
if (gestureVelocity < -50) {
this.props.onGestureCanceled && this.props.onGestureCanceled();
this._reset(immediateIndex, resetDuration);
return;
}
if (gestureVelocity > 50) {
this.props.onGestureFinish && this.props.onGestureFinish();
this._goBack(immediateIndex, goBackDuration);
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.props.onGestureFinish && this.props.onGestureFinish();
this._goBack(immediateIndex, goBackDuration);
} else {
this.props.onGestureCanceled && this.props.onGestureCanceled();
this._reset(immediateIndex, resetDuration);
}
});
};
_getHeaderMode() {
if (this.props.headerMode) {
return this.props.headerMode;
@@ -551,10 +638,17 @@ class StackViewLayout extends React.Component {
return 'fade-in-place';
}
_getheaderBackTitleVisible() {
_getHeaderBackTitleVisible() {
const { headerBackTitleVisible } = this.props;
const layoutPreset = this._getHeaderLayoutPreset();
return headerBackTitleVisible;
// Even when we align to center on Android, people should need to opt-in to
// showing the back title
const enabledByDefault = !(layoutPreset === 'left' || Platform.OS === 'android');
return typeof headerBackTitleVisible === 'boolean'
? headerBackTitleVisible
: enabledByDefault;
}
_renderInnerScene(scene) {
@@ -591,18 +685,38 @@ class StackViewLayout extends React.Component {
return TransitionConfigs.getTransitionConfig(
this.props.transitionConfig,
this.props.transitionProps,
{
...this.props.transitionProps,
position: this._getPosition(),
},
this.props.lastTransitionProps,
isModal
);
};
_getPosition = () => {
if (!this.state.gesturePosition) {
return this.props.transitionProps.position;
} else {
let { gesturePosition } = this.state;
let staticPosition = Animated.add(
this.props.transitionProps.position,
Animated.multiply(-1, this.props.transitionProps.position)
);
return Animated.add(gesturePosition, staticPosition);
}
};
_renderCard = scene => {
const { screenInterpolator } = this._getTransitionConfig();
const style =
screenInterpolator &&
screenInterpolator({ ...this.props.transitionProps, scene });
screenInterpolator({
...this.props.transitionProps,
position: this._getPosition(),
scene,
});
// When using a floating header, we need to add some top
// padding on the scene.
@@ -618,8 +732,11 @@ class StackViewLayout extends React.Component {
<Card
{...this.props.transitionProps}
key={`card_${scene.key}`}
position={this._getPosition()}
realPosition={this.props.transitionProps.position}
animatedStyle={style}
transparent={this.props.transparentCard}
style={[style, { paddingTop }, this.props.cardStyle]}
style={[{ paddingTop }, this.props.cardStyle]}
scene={scene}
>
{this._renderInnerScene(scene)}

View File

@@ -1,6 +1,8 @@
import { I18nManager } from 'react-native';
import getSceneIndicesForInterpolationInputRange from '../../utils/getSceneIndicesForInterpolationInputRange';
const EPS = 1e-5;
/**
* Utility that builds the style for the card in the cards stack.
*
@@ -47,11 +49,6 @@ function forHorizontal(props) {
const { first, last } = interpolate;
const index = scene.index;
const opacity = position.interpolate({
inputRange: [first, first + 0.01, index, last - 0.01, last],
outputRange: [0, 1, 1, 0.85, 0],
extrapolate: 'clamp',
});
const width = layout.initWidth;
const translateX = position.interpolate({
@@ -61,11 +58,25 @@ function forHorizontal(props) {
: [width, 0, width * -0.3],
extrapolate: 'clamp',
});
const translateY = 0;
// TODO: add flag to disable shadow
const shadowOpacity = position.interpolate({
inputRange: [first, index, last],
outputRange: [0, 0.7, 0],
extrapolate: 'clamp',
});
// TODO: disable overlay by default, add flag to enable
let overlayOpacity = position.interpolate({
inputRange: [index, last - 0.5, last, last + EPS],
outputRange: [0, 0.05, 0.05, 0],
extrapolate: 'clamp',
});
return {
opacity,
transform: [{ translateX }, { translateY }],
transform: [{ translateX }],
overlayOpacity,
shadowOpacity,
};
}
@@ -84,23 +95,15 @@ function forVertical(props) {
const { first, last } = interpolate;
const index = scene.index;
const opacity = position.interpolate({
inputRange: [first, first + 0.01, index, last - 0.01, last],
outputRange: [0, 1, 1, 0.85, 0],
extrapolate: 'clamp',
});
const height = layout.initHeight;
const translateY = position.interpolate({
inputRange: [first, index, last],
outputRange: [height, 0, 0],
extrapolate: 'clamp',
});
const translateX = 0;
return {
opacity,
transform: [{ translateX }, { translateY }],
transform: [{ translateY }],
};
}

View File

@@ -25,7 +25,7 @@ const SlideFromRightIOS = {
transitionSpec: IOSTransitionSpec,
screenInterpolator: StyleInterpolator.forHorizontal,
containerStyle: {
backgroundColor: '#000',
backgroundColor: '#eee',
},
};
@@ -34,7 +34,7 @@ const ModalSlideFromBottomIOS = {
transitionSpec: IOSTransitionSpec,
screenInterpolator: StyleInterpolator.forVertical,
containerStyle: {
backgroundColor: '#000',
backgroundColor: '#eee',
},
};

View File

@@ -23,6 +23,8 @@ export default function createPointerEventsContainer(Component) {
this._bindPosition();
this._pointerEvents = this._computePointerEvents();
const { navigation, scene } = this.props;
return (
<Component
{...this.props}
@@ -45,12 +47,17 @@ export default function createPointerEventsContainer(Component) {
_bindPosition() {
this._positionListener && this._positionListener.remove();
this._positionListener = new AnimatedValueSubscription(
this.props.position,
this.props.realPosition,
this._onPositionChange
);
}
_onPositionChange = () => {
_onPositionChange = ({ value }) => {
// This should log each frame when releasing the gesture or when pressing
// the back button! If not, something has gone wrong with the animated
// value subscription
// console.log(value);
if (this._component) {
const pointerEvents = this._computePointerEvents();
if (this._pointerEvents !== pointerEvents) {
@@ -61,14 +68,14 @@ export default function createPointerEventsContainer(Component) {
};
_computePointerEvents() {
const { navigation, position, scene } = this.props;
const { navigation, realPosition, scene } = this.props;
if (scene.isStale || navigation.state.index !== scene.index) {
// The scene isn't focused.
return scene.index > navigation.state.index ? 'box-only' : 'none';
}
const offset = position.__getAnimatedValue() - navigation.state.index;
const offset = realPosition.__getAnimatedValue() - navigation.state.index;
if (Math.abs(offset) > MIN_POSITION_OFFSET) {
// The positon is still away from scene's index.
// Scene's children should not receive touches until the position

View File

@@ -25,10 +25,20 @@ class Transitioner extends React.Component {
width: new Animated.Value(0),
};
const position = new Animated.Value(this.props.navigation.state.index);
this._positionListener = position.addListener(({ value }) => {
// This should work until we detach position from a view! so we have to be
// careful to not ever detach it, thus the gymnastics in _getPosition in
// StackViewLayout
// This should log each frame when releasing the gesture or when pressing
// the back button! If not, something has gone wrong with the animated
// value subscription
// console.log(value);
});
this.state = {
layout,
position: new Animated.Value(this.props.navigation.state.index),
progress: new Animated.Value(1),
position,
scenes: NavigationScenesReducer(
[],
this.props.navigation.state,
@@ -50,6 +60,7 @@ class Transitioner extends React.Component {
componentWillUnmount() {
this._isMounted = false;
this._positionListener && this._positionListener.remove();
}
// eslint-disable-next-line react/no-deprecated
@@ -90,15 +101,12 @@ class Transitioner extends React.Component {
scenes: nextScenes,
};
const { position, progress } = nextState;
progress.setValue(0);
const { position } = nextState;
const toValue = nextProps.navigation.state.index;
this._prevTransitionProps = this._transitionProps;
this._transitionProps = buildTransitionProps(nextProps, nextState);
const toValue = nextProps.navigation.state.index;
if (!this._transitionProps.navigation.state.isTransitioning) {
this.setState(nextState, async () => {
if (nextProps.onTransitionStart) {
@@ -110,13 +118,27 @@ class Transitioner extends React.Component {
await result;
}
}
progress.setValue(1);
position.setValue(toValue);
this._onTransitionEnd();
});
return;
}
// update scenes and play the transition
this._isTransitionRunning = true;
this.setState(nextState, async () => {
if (nextProps.onTransitionStart) {
const result = nextProps.onTransitionStart(
this._transitionProps,
this._prevTransitionProps
);
if (result instanceof Promise) {
await result;
}
}
});
// get the transition spec.
const transitionUserSpec = nextProps.configureTransition
? nextProps.configureTransition(
@@ -133,38 +155,14 @@ class Transitioner extends React.Component {
const { timing } = transitionSpec;
delete transitionSpec.timing;
const positionHasChanged = position.__getValue() !== toValue;
// if swiped back, indexHasChanged == true && positionHasChanged == false
const animations =
indexHasChanged && positionHasChanged
? [
timing(progress, {
...transitionSpec,
toValue: 1,
}),
timing(position, {
...transitionSpec,
toValue: nextProps.navigation.state.index,
}),
]
: [];
// update scenes and play the transition
this._isTransitionRunning = true;
this.setState(nextState, async () => {
if (nextProps.onTransitionStart) {
const result = nextProps.onTransitionStart(
this._transitionProps,
this._prevTransitionProps
);
if (result instanceof Promise) {
await result;
}
}
Animated.parallel(animations).start(this._onTransitionEnd);
});
const positionHasChanged = position.__getValue() !== toValue;
if (indexHasChanged && positionHasChanged) {
timing(position, {
...transitionSpec,
toValue: nextProps.navigation.state.index,
}).start(this._onTransitionEnd);
}
}
render() {
@@ -247,7 +245,7 @@ class Transitioner extends React.Component {
function buildTransitionProps(props, state) {
const { navigation } = props;
const { layout, position, progress, scenes } = state;
const { layout, position, scenes } = state;
const scene = scenes.find(isSceneActive);
@@ -257,7 +255,6 @@ function buildTransitionProps(props, state) {
layout,
navigation,
position,
progress,
scenes,
scene,
index: scene.index,

File diff suppressed because it is too large Load Diff