import React from 'react'; import { Animated, StyleSheet, Platform, View, I18nManager, Easing, Dimensions, } from 'react-native'; import { SceneView, StackActions, NavigationActions, NavigationProvider, } from '@react-navigation/core'; import { withOrientation } from '@react-navigation/native'; 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 HeaderStyleInterpolator from '../Header/HeaderStyleInterpolator'; import StackGestureContext from '../../utils/StackGestureContext'; import clamp from '../../utils/clamp'; import { supportsImprovedSpringAnimation } from '../../utils/ReactNativeFeatures'; const IPHONE_XS_HEIGHT = 812; // iPhone X and XS const IPHONE_XR_HEIGHT = 896; // iPhone XR and XS Max const { width: WINDOW_WIDTH, height: WINDOW_HEIGHT } = Dimensions.get('window'); const IS_IPHONE_X = Platform.OS === 'ios' && !Platform.isPad && !Platform.isTVOS && (WINDOW_HEIGHT === IPHONE_XS_HEIGHT || WINDOW_WIDTH === IPHONE_XS_HEIGHT || WINDOW_HEIGHT === IPHONE_XR_HEIGHT || WINDOW_WIDTH === IPHONE_XR_HEIGHT); const EaseInOut = Easing.inOut(Easing.ease); /** * Enumerate possible values for validation */ const HEADER_LAYOUT_PRESET = ['center', 'left']; const HEADER_TRANSITION_PRESET = ['fade-in-place', 'uikit']; const HEADER_BACKGROUND_TRANSITION_PRESET = ['toggle', 'fade', 'translate']; /** * The max duration of the card animation in milliseconds after released gesture. * The actual duration should be always less then that because the rest distance * is always less then the full distance of the layout. */ const ANIMATION_DURATION = 500; /** * The gesture distance threshold to trigger the back behavior. For instance, * `1/2` means that moving greater than 1/2 of the width of the screen will * trigger a back action */ const POSITION_THRESHOLD = 1 / 2; /** * The distance of touch start from the edge of the screen where the gesture will be recognized */ const GESTURE_RESPONSE_DISTANCE_HORIZONTAL = 50; const GESTURE_RESPONSE_DISTANCE_VERTICAL = 135; const USE_NATIVE_DRIVER = true; const getDefaultHeaderHeight = isLandscape => { if (Platform.OS === 'ios') { if (isLandscape && !Platform.isPad) { return 32; } else if (IS_IPHONE_X) { return 88; } else { return 64; } } else { return 56; } }; class StackViewLayout extends React.Component { /** * 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 = 0; /** * 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 = null; 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 // between screens with headers and those without headers smooth. // This is not a great heuristic here. We don't know synchronously // on mount what the header height is so we have just used the most // common cases here. floatingHeaderHeight: getDefaultHeaderHeight(props.isLandscape), gesturePosition: null, }; } _renderHeader(scene, headerMode) { const { options } = scene.descriptor; const { header } = options; if (__DEV__ && typeof header === 'string') { throw new Error( `Invalid header value: "${header}". The header option must be a valid React component or null, not a string.` ); } if (header === null && headerMode === 'screen') { return null; } // check if it's a react element if (React.isValidElement(header)) { return header; } // Handle the case where the header option is a function, and provide the default const renderHeader = header || (props =>
); let { headerLeftInterpolator, headerTitleInterpolator, headerRightInterpolator, headerBackgroundInterpolator, } = this._getTransitionConfig(); let backgroundTransitionPresetInterpolator = this._getHeaderBackgroundTransitionPreset(); if (backgroundTransitionPresetInterpolator) { headerBackgroundInterpolator = backgroundTransitionPresetInterpolator; } const { transitionProps, ...passProps } = this.props; return ( {renderHeader({ ...passProps, ...transitionProps, position: this._getPosition(), scene, mode: headerMode, transitionPreset: this._getHeaderTransitionPreset(), layoutPreset: this._getHeaderLayoutPreset(), backTitleVisible: this._getHeaderBackTitleVisible(), leftInterpolator: headerLeftInterpolator, titleInterpolator: headerTitleInterpolator, rightInterpolator: headerRightInterpolator, backgroundInterpolator: headerBackgroundInterpolator, })} ); } _reset(resetToIndex, duration) { if (Platform.OS === 'ios' && supportsImprovedSpringAnimation()) { Animated.spring(this.props.transitionProps.position, { toValue: resetToIndex, stiffness: 6000, damping: 100, mass: 3, overshootClamping: true, restDisplacementThreshold: 0.01, restSpeedThreshold: 0.01, useNativeDriver: USE_NATIVE_DRIVER, }).start(); } else { Animated.timing(this.props.transitionProps.position, { toValue: resetToIndex, duration, easing: EaseInOut, useNativeDriver: USE_NATIVE_DRIVER, }).start(); } } _goBack(backFromIndex, duration) { const { navigation, position, scenes } = this.props.transitionProps; const toValue = Math.max(backFromIndex - 1, 0); // set temporary index for gesture handler to respect until the action is // dispatched at the end of the transition. this._immediateIndex = toValue; const onCompleteAnimation = () => { this._immediateIndex = null; const backFromScene = scenes.find(s => s.index === toValue + 1); if (backFromScene) { navigation.dispatch( NavigationActions.back({ key: backFromScene.route.key, immediate: true, }) ); navigation.dispatch(StackActions.completeTransition()); } }; if (Platform.OS === 'ios' && supportsImprovedSpringAnimation()) { Animated.spring(position, { toValue, stiffness: 7000, damping: 300, mass: 3, overshootClamping: true, restDisplacementThreshold: 0.01, restSpeedThreshold: 0.01, useNativeDriver: USE_NATIVE_DRIVER, }).start(onCompleteAnimation); } else { Animated.timing(position, { toValue, duration, easing: EaseInOut, useNativeDriver: USE_NATIVE_DRIVER, }).start(onCompleteAnimation); } } _onFloatingHeaderLayout = e => { this.setState({ floatingHeaderHeight: e.nativeEvent.layout.height }); }; render() { let floatingHeader = null; const headerMode = this._getHeaderMode(); if (headerMode === 'float') { const { scene } = this.props.transitionProps; floatingHeader = ( {this._renderHeader(scene, headerMode)} ); } const { 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 containerStyle = [ styles.container, this._getTransitionConfig().containerStyle, ]; return ( 0 && gesturesEnabled} > {scenes.map(s => this._renderCard(s))} {floatingHeader} ); } componentDidUpdate(prevProps) { const { state: prevState } = prevProps.transitionProps.navigation; const { state } = this.props.transitionProps.navigation; if (prevState.index !== state.index) { this._maybeCancelGesture(); } } _getGestureResponseDistance = () => { const { scene } = this.props.transitionProps; const { options } = scene.descriptor; const { gestureResponseDistance: userGestureResponseDistance = {}, } = options; // Doesn't make sense for a response distance of 0, so this works fine return this._isModal() ? userGestureResponseDistance.vertical || GESTURE_RESPONSE_DISTANCE_VERTICAL : userGestureResponseDistance.horizontal || GESTURE_RESPONSE_DISTANCE_HORIZONTAL; }; _gestureActivationCriteria = () => { let { layout } = this.props.transitionProps; let gestureResponseDistance = this._getGestureResponseDistance(); let isMotionInverted = this._isMotionInverted(); if (this._isMotionVertical()) { let height = layout.height.__getValue(); return { maxDeltaX: 15, minOffsetY: isMotionInverted ? -5 : 5, hitSlop: isMotionInverted ? { top: -height + gestureResponseDistance } : { bottom: -height + gestureResponseDistance }, }; } else { let width = layout.width.__getValue(); let hitSlop = -width + gestureResponseDistance; return { minOffsetX: isMotionInverted ? -5 : 5, maxDeltaY: 20, hitSlop: 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._isModal(); }; _isModal = () => { 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; if (this._isModal()) { return gestureDirection === 'inverted'; } else { 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 = ({ translationX }) => { let { transitionProps: { navigation, layout }, } = this.props; let { index } = navigation.state; // TODO: remove this __getValue! let distance = layout.width.__getValue(); let x = this._isMotionInverted() ? -1 * translationX : translationX; let value = index - x / distance; return clamp(index - 1, value, index); }; _computeVerticalGestureValue = ({ translationY }) => { let { transitionProps: { navigation, layout }, } = this.props; let { index } = navigation.state; // TODO: remove this __getValue! let distance = layout.height.__getValue(); let y = this._isMotionInverted() ? -1 * translationY : translationY; let value = index - y / distance; return clamp(index - 1, value, index); }; _handlePanGestureStateChange = ({ nativeEvent }) => { if (nativeEvent.oldState === State.ACTIVE) { // Gesture was cancelled! For example, some navigation state update // arrived while the gesture was active that cancelled it out if (!this.state.gesturePosition) { return; } 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); } } }; // note: this will not animated so nicely because the position is unaware // of the gesturePosition, so if we are in the middle of swiping the screen away // and back is programatically fired then we will reset to the initial position // and animate from there _maybeCancelGesture = () => { if (this.state.gesturePosition) { this.setState({ gesturePosition: null }); } }; _handleActivateGestureHorizontal = () => { 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 = () => { let { index } = this.props.transitionProps.navigation.state; if (this._isMotionInverted()) { this.setState({ gesturePosition: Animated.add( index, Animated.divide( this.gestureY, this.props.transitionProps.layout.height ) ).interpolate({ inputRange: [index - 1, index], outputRange: [index - 1, index], extrapolate: 'clamp', }), }); } else { 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 }, } = 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 }, } = 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 isMotionInverted = this._isMotionInverted(); const movementDirection = isMotionInverted ? -1 : 1; const movedDistance = movementDirection * nativeEvent.translationY; const gestureVelocity = movementDirection * nativeEvent.velocityY; const defaultVelocity = distance / ANIMATION_DURATION; const velocity = Math.max(Math.abs(gestureVelocity), defaultVelocity); const resetDuration = isMotionInverted ? (distance - movedDistance) / velocity : movedDistance / velocity; const goBackDuration = isMotionInverted ? movedDistance / velocity : (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; } if (Platform.OS === 'android' || this.props.mode === 'modal') { return 'screen'; } return 'float'; } _getHeaderBackgroundTransitionPreset() { const { headerBackgroundTransitionPreset } = this.props; if (headerBackgroundTransitionPreset) { if ( HEADER_BACKGROUND_TRANSITION_PRESET.includes( headerBackgroundTransitionPreset ) ) { if (headerBackgroundTransitionPreset === 'fade') { return HeaderStyleInterpolator.forBackgroundWithFade; } else if (headerBackgroundTransitionPreset === 'translate') { return HeaderStyleInterpolator.forBackgroundWithTranslation; } else if (headerBackgroundTransitionPreset === 'toggle') { return HeaderStyleInterpolator.forBackgroundWithInactiveHidden; } } else { if (__DEV__) { console.error( `Invalid configuration applied for headerBackgroundTransitionPreset - expected one of ${HEADER_BACKGROUND_TRANSITION_PRESET.join( ', ' )} but received ${JSON.stringify(headerBackgroundTransitionPreset)}` ); } } } return null; } _getHeaderLayoutPreset() { const { headerLayoutPreset } = this.props; if (headerLayoutPreset) { if (__DEV__) { if ( this._getHeaderTransitionPreset() === 'uikit' && headerLayoutPreset === 'left' && Platform.OS === 'ios' ) { console.warn( `headerTransitionPreset with the value 'uikit' is incompatible with headerLayoutPreset 'left'` ); } } if (HEADER_LAYOUT_PRESET.includes(headerLayoutPreset)) { return headerLayoutPreset; } if (__DEV__) { console.error( `Invalid configuration applied for headerLayoutPreset - expected one of ${HEADER_LAYOUT_PRESET.join( ', ' )} but received ${JSON.stringify(headerLayoutPreset)}` ); } } if (Platform.OS === 'android') { return 'left'; } else { return 'center'; } } _getHeaderTransitionPreset() { // On Android or with header mode screen, we always just use in-place, // we ignore the option entirely (at least until we have other presets) if (Platform.OS === 'android' || this._getHeaderMode() === 'screen') { return 'fade-in-place'; } const { headerTransitionPreset } = this.props; if (headerTransitionPreset) { if (HEADER_TRANSITION_PRESET.includes(headerTransitionPreset)) { return headerTransitionPreset; } if (__DEV__) { console.error( `Invalid configuration applied for headerTransitionPreset - expected one of ${HEADER_TRANSITION_PRESET.join( ', ' )} but received ${JSON.stringify(headerTransitionPreset)}` ); } } return 'fade-in-place'; } _getHeaderBackTitleVisible() { const { headerBackTitleVisible } = this.props; const layoutPreset = this._getHeaderLayoutPreset(); // 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) { const { navigation, getComponent } = scene.descriptor; const SceneComponent = getComponent(); const { screenProps } = this.props; const headerMode = this._getHeaderMode(); if (headerMode === 'screen') { return ( {this._renderHeader(scene, headerMode)} ); } return ( ); } _getTransitionConfig = () => { return TransitionConfigs.getTransitionConfig( this.props.transitionConfig, { ...this.props.transitionProps, position: this._getPosition(), }, this.props.lastTransitionProps, this._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, shadowEnabled: this.props.shadowEnabled, cardOverlayEnabled: this.props.cardOverlayEnabled, position: this._getPosition(), scene, }); // When using a floating header, we need to add some top // padding on the scene. const { options } = scene.descriptor; const hasHeader = options.header !== null; const headerMode = this._getHeaderMode(); let paddingTop = 0; if (hasHeader && headerMode === 'float' && !options.headerTransparent) { paddingTop = this.state.floatingHeaderHeight; } return ( {this._renderInnerScene(scene)} ); }; } const styles = StyleSheet.create({ container: { flex: 1, // Header is physically rendered after scenes so that Header won't be // covered by the shadows of the scenes. // That said, we'd have use `flexDirection: 'column-reverse'` to move // Header above the scenes. flexDirection: 'column-reverse', overflow: 'hidden', }, scenes: { flex: 1, }, floatingHeader: { position: 'absolute', left: 0, top: 0, right: 0, }, }); export default withOrientation(StackViewLayout);