/* @flow */
import * as React from 'react';
import PropTypes from 'prop-types';
import {
Animated,
I18nManager,
PanResponder,
StyleSheet,
View,
Platform,
} from 'react-native';
import { PagerRendererPropType } from './PropTypes';
import type { PagerRendererProps } from './TypeDefinitions';
type GestureEvent = {
nativeEvent: {
changedTouches: Array<*>,
identifier: number,
locationX: number,
locationY: number,
pageX: number,
pageY: number,
target: number,
timestamp: number,
touches: Array<*>,
},
};
type GestureState = {
stateID: number,
moveX: number,
moveY: number,
x0: number,
y0: number,
dx: number,
dy: number,
vx: number,
vy: number,
numberActiveTouches: number,
};
type Props<T> = PagerRendererProps<T> & {
swipeDistanceThreshold?: number,
swipeVelocityThreshold?: number,
};
const DEAD_ZONE = 12;
const DefaultTransitionSpec = {
timing: Animated.spring,
tension: 300,
friction: 35,
};
export default class PagerPan<T: *> extends React.Component<Props<T>> {
static propTypes = {
...PagerRendererPropType,
swipeDistanceThreshold: PropTypes.number,
swipeVelocityThreshold: PropTypes.number,
};
static defaultProps = {
canJumpToTab: () => true,
initialLayout: {
height: 0,
width: 0,
},
};
componentDidUpdate(prevProps: Props<T>) {
this._currentIndex = this.props.navigationState.index;
if (
prevProps.navigationState.routes.length !==
this.props.navigationState.routes.length ||
prevProps.layout.width !== this.props.layout.width
) {
this._transitionTo(this.props.navigationState.index, false);
} else if (
prevProps.navigationState.index !== this.props.navigationState.index
) {
this._transitionTo(this.props.navigationState.index);
}
}
_currentIndex = this.props.navigationState.index;
_pendingIndex: ?number;
_isMovingHorizontally = (evt: GestureEvent, gestureState: GestureState) => {
return (
Math.abs(gestureState.dx) > Math.abs(gestureState.dy * 2) &&
Math.abs(gestureState.vx) > Math.abs(gestureState.vy * 2)
);
};
_canMoveScreen = (evt: GestureEvent, gestureState: GestureState) => {
if (this.props.swipeEnabled === false) {
return false;
}
const {
navigationState: { routes },
} = this.props;
return (
this._isMovingHorizontally(evt, gestureState) &&
((gestureState.dx >= DEAD_ZONE && this._currentIndex > 0) ||
(gestureState.dx <= -DEAD_ZONE &&
this._currentIndex < routes.length - 1))
);
};
_startGesture = () => {
this.props.onSwipeStart && this.props.onSwipeStart();
this.props.panX.stopAnimation();
};
_respondToGesture = (evt: GestureEvent, gestureState: GestureState) => {
const {
navigationState: { routes, index },
} = this.props;
if (
// swiping left
(gestureState.dx > 0 && index <= 0) ||
// swiping right
(gestureState.dx < 0 && index >= routes.length - 1)
) {
return;
}
this.props.panX.setValue(gestureState.dx);
};
_finishGesture = (evt: GestureEvent, gestureState: GestureState) => {
const {
navigationState,
layout,
swipeDistanceThreshold = layout.width / 1.75,
} = this.props;
let { swipeVelocityThreshold = 0.15 } = this.props;
this.props.onSwipeEnd && this.props.onSwipeEnd();
if (Platform.OS === 'android') {
// on Android, velocity is way lower due to timestamp being in nanosecond
// normalize it to have the same velocity on both iOS and Android
swipeVelocityThreshold /= 1000000;
}
const currentIndex =
typeof this._pendingIndex === 'number'
? this._pendingIndex
: this._currentIndex;
let nextIndex = currentIndex;
if (
Math.abs(gestureState.dx) > Math.abs(gestureState.dy) &&
Math.abs(gestureState.vx) > Math.abs(gestureState.vy) &&
(Math.abs(gestureState.dx) > swipeDistanceThreshold ||
Math.abs(gestureState.vx) > swipeVelocityThreshold)
) {
nextIndex = Math.round(
Math.min(
Math.max(
0,
currentIndex - gestureState.dx / Math.abs(gestureState.dx)
),
navigationState.routes.length - 1
)
);
this._currentIndex = nextIndex;
}
if (
!isFinite(nextIndex) ||
!this.props.canJumpToTab({
route: this.props.navigationState.routes[nextIndex],
})
) {
nextIndex = currentIndex;
}
this._transitionTo(nextIndex);
};
_transitionTo = (index: number, animated: boolean = true) => {
const offset = -index * this.props.layout.width;
const route = this.props.navigationState.routes[index];
if (this.props.animationEnabled === false || animated === false) {
this.props.panX.setValue(0);
this.props.offsetX.setValue(offset);
this.props.jumpTo(route.key);
return;
}
const { timing, ...transitionConfig } = DefaultTransitionSpec;
Animated.parallel([
timing(this.props.panX, {
...transitionConfig,
toValue: 0,
}),
timing(this.props.offsetX, {
...transitionConfig,
toValue: offset,
}),
]).start(({ finished }) => {
if (finished) {
this.props.jumpTo(route.key);
this.props.onAnimationEnd && this.props.onAnimationEnd();
this._pendingIndex = null;
}
});
this._pendingIndex = index;
};
_panResponder = PanResponder.create({
onMoveShouldSetPanResponder: this._canMoveScreen,
onMoveShouldSetPanResponderCapture: this._canMoveScreen,
onPanResponderGrant: this._startGesture,
onPanResponderMove: this._respondToGesture,
onPanResponderTerminate: this._finishGesture,
onPanResponderRelease: this._finishGesture,
onPanResponderTerminationRequest: () => true,
});
render() {
const { panX, offsetX, navigationState, layout, children } = this.props;
const { width } = layout;
const { routes } = navigationState;
const maxTranslate = width * (routes.length - 1);
const translateX = Animated.multiply(
Animated.add(panX, offsetX).interpolate({
inputRange: [-maxTranslate, 0],
outputRange: [-maxTranslate, 0],
extrapolate: 'clamp',
}),
I18nManager.isRTL ? -1 : 1
);
return (
<Animated.View
style={[
styles.sheet,
width
? {
width: routes.length * width,
transform: [{ translateX }],
}
: null,
]}
{...this._panResponder.panHandlers}
>
{React.Children.map(children, (child, i) => {
const route = navigationState.routes[i];
const focused = i === navigationState.index;
return (
<View
key={route.key}
testID={this.props.getTestID({ route })}
style={
width ? { width } : focused ? StyleSheet.absoluteFill : null
}
>
{focused || width ? child : null}
</View>
);
})}
</Animated.View>
);
}
}
const styles = StyleSheet.create({
sheet: {
flex: 1,
flexDirection: 'row',
alignItems: 'stretch',
},
});