import * as React from 'react'; import { Animated, StyleSheet, Platform, View, I18nManager, Easing, Dimensions, StyleProp, ViewStyle, LayoutChangeEvent, } 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 as GestureState, PanGestureHandlerGestureEvent, GestureHandlerGestureEventNativeEvent, PanGestureHandlerEventExtra, } 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'; import { Scene, HeaderMode, TransitionProps, TransitionConfig, HeaderTransitionConfig, HeaderProps, } from '../../types'; type Props = { mode: 'modal' | 'card'; headerMode: 'screen' | 'float'; headerLayoutPreset: 'left' | 'center'; headerTransitionPreset: 'fade-in-place' | 'uikit'; headerBackgroundTransitionPreset: 'fade' | 'translate' | 'toggle'; headerBackTitleVisible?: boolean; isLandscape: boolean; shadowEnabled?: boolean; cardOverlayEnabled?: boolean; transparentCard?: boolean; cardStyle?: StyleProp; transitionProps: TransitionProps; lastTransitionProps?: TransitionProps; transitionConfig: ( transitionProps: TransitionProps, prevTransitionProps?: TransitionProps, isModal?: boolean ) => HeaderTransitionConfig; onGestureBegin?: () => void; onGestureEnd?: () => void; onGestureCanceled?: () => void; screenProps?: unknown; }; type State = { floatingHeaderHeight: number; }; 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' && // @ts-ignore !Platform.isPad && // @ts-ignore !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: boolean) => { if (Platform.OS === 'ios') { // @ts-ignore if (isLandscape && !Platform.isPad) { return 32; } else if (IS_IPHONE_X) { return 88; } else { return 64; } } else if (Platform.OS === 'android') { return 56; } else { return 64; } }; class StackViewLayout extends React.Component { private panGestureRef: React.RefObject; private gestureX: Animated.Value; private gestureY: Animated.Value; private positionSwitch: Animated.Value; private gestureSwitch: Animated.AnimatedInterpolation; private gestureEvent: (...args: any[]) => void; private gesturePosition: Animated.AnimatedInterpolation | undefined; // @ts-ignore private position: Animated.Value; /** * 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. */ private immediateIndex: number | null = null; private transitionConfig: | HeaderTransitionConfig & TransitionConfig | undefined; private prevProps: Props | undefined; constructor(props: Props) { super(props); this.panGestureRef = React.createRef(); this.gestureX = new Animated.Value(0); this.gestureY = new Animated.Value(0); this.positionSwitch = new Animated.Value(1); if (Animated.subtract) { this.gestureSwitch = Animated.subtract(1, this.positionSwitch); } else { this.gestureSwitch = Animated.add( 1, Animated.multiply(-1, this.positionSwitch) ); } this.gestureEvent = Animated.event( [ { nativeEvent: { translationX: this.gestureX, translationY: this.gestureY, }, }, ], { useNativeDriver: USE_NATIVE_DRIVER, } ); 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), }; } private renderHeader(scene: Scene, headerMode: 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 = // @ts-ignore TS warns about missing props, but they are in default props header || ((props: HeaderProps) =>
); let { headerLeftInterpolator, headerTitleInterpolator, headerRightInterpolator, headerBackgroundInterpolator, headerLayoutInterpolator, } = this.transitionConfig as HeaderTransitionConfig; const backgroundTransitionPresetInterpolator = this.getHeaderBackgroundTransitionPreset(); if (backgroundTransitionPresetInterpolator) { headerBackgroundInterpolator = backgroundTransitionPresetInterpolator; } const { transitionProps, ...passProps } = this.props; return ( {renderHeader({ ...passProps, ...transitionProps, position: this.position, scene, mode: headerMode, transitionPreset: this.getHeaderTransitionPreset(), layoutPreset: this.getHeaderLayoutPreset(), backTitleVisible: this.getHeaderBackTitleVisible(), leftInterpolator: headerLeftInterpolator, titleInterpolator: headerTitleInterpolator, rightInterpolator: headerRightInterpolator, backgroundInterpolator: headerBackgroundInterpolator, layoutInterpolator: headerLayoutInterpolator, })} ); } private reset(resetToIndex: number, duration: number) { if (Platform.OS === 'ios' && supportsImprovedSpringAnimation()) { // @ts-ignore 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 { // @ts-ignore Animated.timing(this.props.transitionProps.position, { toValue: resetToIndex, duration, easing: EaseInOut, useNativeDriver: USE_NATIVE_DRIVER, }).start(); } } private goBack(backFromIndex: number, duration: number) { 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()) { // @ts-ignore 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 { // @ts-ignore Animated.timing(position, { toValue, duration, easing: EaseInOut, useNativeDriver: USE_NATIVE_DRIVER, }).start(onCompleteAnimation); } } private handleFloatingHeaderLayout = (e: LayoutChangeEvent) => { const { height } = e.nativeEvent.layout; if (height !== this.state.floatingHeaderHeight) { this.setState({ floatingHeaderHeight: height }); } }; private prepareAnimated() { if (this.props === this.prevProps) { return; } this.prevProps = this.props; this.prepareGesture(); this.preparePosition(); this.prepareTransitionConfig(); } render() { this.prepareAnimated(); const { transitionProps } = this.props; const { navigation: { state: { index }, }, scenes, } = transitionProps; const headerMode = this.getHeaderMode(); let floatingHeader = null; if (headerMode === 'float') { const { scene } = transitionProps; floatingHeader = ( {this.renderHeader(scene, headerMode)} ); } return ( 0 && this.isGestureEnabled()} > {scenes.map(this.renderCard)} {floatingHeader} ); } componentDidUpdate(prevProps: Props) { const { state: prevState } = prevProps.transitionProps.navigation; const { state } = this.props.transitionProps.navigation; if (prevState.index !== state.index) { this.maybeCancelGesture(); } } private getGestureResponseDistance() { const { scene } = this.props.transitionProps; const { options } = scene.descriptor; const { gestureResponseDistance: userGestureResponseDistance = {} as { vertical?: number; horizontal?: number; }, } = 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; } private gestureActivationCriteria() { const { layout } = this.props.transitionProps; const gestureResponseDistance = this.getGestureResponseDistance(); const isMotionInverted = this.isMotionInverted(); if (this.isMotionVertical()) { // @ts-ignore const height: number = layout.height.__getValue(); return { maxDeltaX: 15, minOffsetY: isMotionInverted ? -5 : 5, hitSlop: isMotionInverted ? { top: -height + gestureResponseDistance } : { bottom: -height + gestureResponseDistance }, }; } else { // @ts-ignore const width: number = layout.width.__getValue(); const hitSlop = -width + gestureResponseDistance; return { minOffsetX: isMotionInverted ? -5 : 5, maxDeltaY: 20, hitSlop: isMotionInverted ? { left: hitSlop } : { right: hitSlop }, }; } } private isGestureEnabled() { const gesturesEnabled = this.props.transitionProps.scene.descriptor.options .gesturesEnabled; return typeof gesturesEnabled === 'boolean' ? gesturesEnabled : Platform.OS === 'ios'; } private isMotionVertical() { return this.isModal(); } private isModal() { return this.props.mode === 'modal'; } // This only currently applies to the horizontal gesture! private 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; } } private computeHorizontalGestureValue({ translationX, }: { translationX: number; }) { const { transitionProps: { navigation, layout }, } = this.props; const { index } = navigation.state; // TODO: remove this __getValue! // @ts-ignore const distance: number = layout.width.__getValue(); const x = this.isMotionInverted() ? -1 * translationX : translationX; const value = index - x / distance; return clamp(index - 1, value, index); } private computeVerticalGestureValue({ translationY, }: { translationY: number; }) { const { transitionProps: { navigation, layout }, } = this.props; const { index } = navigation.state; // TODO: remove this __getValue! // @ts-ignore const distance: number = layout.height.__getValue(); const y = this.isMotionInverted() ? -1 * translationY : translationY; const value = index - y / distance; return clamp(index - 1, value, index); } private handlePanGestureStateChange = ({ nativeEvent, }: PanGestureHandlerGestureEvent) => { // @ts-ignore if (nativeEvent.oldState === GestureState.ACTIVE) { // Gesture was cancelled! For example, some navigation state update // arrived while the gesture was active that cancelled it out // @ts-ignore if (this.positionSwitch.__getValue() === 1) { return; } if (this.isMotionVertical()) { this.handleReleaseVertical(nativeEvent); } else { this.handleReleaseHorizontal(nativeEvent); } } else if (nativeEvent.state === GestureState.ACTIVE) { this.props.onGestureBegin && this.props.onGestureBegin(); // Switch to using gesture position this.positionSwitch.setValue(0); // By enabling the gesture switch and ignoring the position here we // end up with a quick jump to the initial value and then back to the // gesture. While this isn't ideal, it's preferred over preventing new // gestures during the animation (all gestures should be interruptible) // and we will properly fix it (interruptible and from the correct position) // when we integrate reanimated. If you prefer to prevent gestures during // transitions, then fork this library, comment the positionSwitch value set above, // and uncomment the following two lines. // if (!this.props.transitionProps.position._animation) { // this.positionSwitch.setValue(0); // } } }; // 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 private maybeCancelGesture() { this.positionSwitch.setValue(1); } private prepareGesture() { if (!this.isGestureEnabled()) { // @ts-ignore if (this.positionSwitch.__getValue() !== 1) { this.positionSwitch.setValue(1); } this.gesturePosition = undefined; return; } // We can't run the gesture if width or height layout is unavailable if ( // @ts-ignore this.props.transitionProps.layout.width.__getValue() === 0 || // @ts-ignore this.props.transitionProps.layout.height.__getValue() === 0 ) { return; } if (this.isMotionVertical()) { this.prepareGestureVertical(); } else { this.prepareGestureHorizontal(); } } private prepareGestureHorizontal() { const { index } = this.props.transitionProps.navigation.state; if (this.isMotionInverted()) { this.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.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', }); } } private prepareGestureVertical() { const { index } = this.props.transitionProps.navigation.state; if (this.isMotionInverted()) { this.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.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', }); } } private handleReleaseHorizontal( nativeEvent: GestureHandlerGestureEventNativeEvent & PanGestureHandlerEventExtra ) { 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 // @ts-ignore 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. const value = this.computeHorizontalGestureValue(nativeEvent); position.setValue(value); this.positionSwitch.setValue(1); // 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.onGestureEnd && this.props.onGestureEnd(); 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.onGestureEnd && this.props.onGestureEnd(); this.goBack(immediateIndex, goBackDuration); } else { this.props.onGestureCanceled && this.props.onGestureCanceled(); this.reset(immediateIndex, resetDuration); } } private handleReleaseVertical( nativeEvent: GestureHandlerGestureEventNativeEvent & PanGestureHandlerEventExtra ) { 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 // @ts-ignore 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; const value = this.computeVerticalGestureValue(nativeEvent); position.setValue(value); this.positionSwitch.setValue(1); // 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.onGestureEnd && this.props.onGestureEnd(); 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.onGestureEnd && this.props.onGestureEnd(); this.goBack(immediateIndex, goBackDuration); } else { this.props.onGestureCanceled && this.props.onGestureCanceled(); this.reset(immediateIndex, resetDuration); } } private getHeaderMode() { if (this.props.headerMode) { return this.props.headerMode; } if (Platform.OS === 'android' || this.props.mode === 'modal') { return 'screen'; } // On web, the float header mode will enable body scrolling and stick the header // to the top of the URL bar when it shrinks and expands. return 'float'; } private 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; } private 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 !== 'ios') { return 'left'; } else { return 'center'; } } private 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 !== 'ios' || 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'; } private 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 !== 'ios' ); return typeof headerBackTitleVisible === 'boolean' ? headerBackTitleVisible : enabledByDefault; } private renderInnerScene(scene: 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 ( ); } private prepareTransitionConfig() { this.transitionConfig = TransitionConfigs.getTransitionConfig( this.props.transitionConfig, { ...this.props.transitionProps, position: this.position, }, this.props.lastTransitionProps, this.isModal() ); } private preparePosition() { if (this.gesturePosition) { // FIXME: this doesn't seem right, there is setValue called in some places // @ts-ignore this.position = Animated.add( Animated.multiply( this.props.transitionProps.position, this.positionSwitch ), Animated.multiply(this.gesturePosition, this.gestureSwitch) ); } else { this.position = this.props.transitionProps.position; } } private renderCard = (scene: Scene) => { const { transitionProps, shadowEnabled, cardOverlayEnabled, transparentCard, cardStyle, } = this.props; const { screenInterpolator } = this.transitionConfig as TransitionConfig; const style = screenInterpolator && screenInterpolator({ ...transitionProps, shadowEnabled, cardOverlayEnabled, position: this.position, 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 floatingContainerStyle: ViewStyle = StyleSheet.absoluteFill as ViewStyle; if (hasHeader && headerMode === 'float' && !options.headerTransparent) { floatingContainerStyle = { ...Platform.select({ web: {}, default: StyleSheet.absoluteFillObject }), 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: { // @ts-ignore position: Platform.select({ default: 'absolute', web: 'fixed' }), left: 0, top: 0, right: 0, }, }); export default withOrientation(StackViewLayout);