import * as React from 'react'; import { Animated, Easing, StyleSheet, View, LayoutChangeEvent, } from 'react-native'; import NavigationScenesReducer from './ScenesReducer'; import { NavigationProp, Scene, SceneDescriptor, TransitionerLayout, TransitionProps, } from '../types'; type TransitionSpec = {}; type Props = { render: ( current: TransitionProps, previous?: TransitionProps ) => React.ReactNode; configureTransition?: ( current: TransitionProps, previous?: TransitionProps ) => TransitionSpec; onTransitionStart?: ( current: TransitionProps, previous?: TransitionProps ) => void | Promise; onTransitionEnd?: ( current: TransitionProps, previous?: TransitionProps ) => void | Promise; navigation: NavigationProp; descriptors: { [key: string]: SceneDescriptor }; screenProps?: unknown; }; type State = { layout: TransitionerLayout; position: Animated.Value; scenes: Scene[]; nextScenes?: Scene[]; }; // Used for all animations unless overriden const DefaultTransitionSpec = { duration: 250, easing: Easing.inOut(Easing.ease), timing: Animated.timing, }; class Transitioner extends React.Component { private positionListener: string; private prevTransitionProps: TransitionProps | undefined; private transitionProps: TransitionProps; private isComponentMounted: boolean; private isTransitionRunning: boolean; private queuedTransition: { prevProps: Props } | null; constructor(props: Props) { super(props); // The initial layout isn't measured. Measured layout will be only available // when the component is mounted. const layout: TransitionerLayout = { height: new Animated.Value(0), initHeight: 0, initWidth: 0, isMeasured: false, 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, scenes: NavigationScenesReducer( [], this.props.navigation.state, null, this.props.descriptors ), }; this.prevTransitionProps = undefined; this.transitionProps = buildTransitionProps(props, this.state); this.isComponentMounted = false; this.isTransitionRunning = false; this.queuedTransition = null; } componentDidMount() { this.isComponentMounted = true; } componentWillUnmount() { this.isComponentMounted = false; this.positionListener && this.state.position.removeListener(this.positionListener); } componentWillReceiveProps(nextProps: Props) { if (this.isTransitionRunning) { if (!this.queuedTransition) { this.queuedTransition = { prevProps: this.props }; } return; } this.startTransition(this.props, nextProps); } private computeScenes = (props: Props, nextProps: Props) => { let nextScenes = NavigationScenesReducer( this.state.scenes, nextProps.navigation.state, props.navigation.state, nextProps.descriptors ); if (!nextProps.navigation.state.isTransitioning) { nextScenes = filterStale(nextScenes); } // Update nextScenes when we change screenProps // This is a workaround for https://github.com/react-navigation/react-navigation/issues/4271 if (nextProps.screenProps !== this.props.screenProps) { this.setState({ nextScenes }); } if (nextScenes === this.state.scenes) { return; } return nextScenes; }; private startTransition(props: Props, nextProps: Props) { const indexHasChanged = props.navigation.state.index !== nextProps.navigation.state.index; let nextScenes = this.computeScenes(props, nextProps); if (!nextScenes) { // prevTransitionProps are the same as transitionProps in this case // because nothing changed this.prevTransitionProps = this.transitionProps; // Unsure if this is actually a good idea... Also related to // https://github.com/react-navigation/react-navigation/issues/5247 // - the animation is interrupted before completion so this ensures // that it is properly set to the final position before firing // onTransitionEnd this.state.position.setValue(props.navigation.state.index); this.handleTransitionEnd(); return; } const nextState = { ...this.state, scenes: nextScenes, }; // grab the position animated value const { position } = nextState; // determine where we are meant to transition to const toValue = nextProps.navigation.state.index; // compute transitionProps this.prevTransitionProps = this.transitionProps; this.transitionProps = buildTransitionProps(nextProps, nextState); let { isTransitioning } = this.transitionProps.navigation.state; // if the state isn't transitioning that is meant to signal that we should // transition immediately to the new index. if the index hasn't changed, do // the same thing here. it's not clear to me why we ever start a transition // when the index hasn't changed, this requires further investigation. if (!isTransitioning || !indexHasChanged) { this.setState(nextState, async () => { if (nextProps.onTransitionStart) { const result = nextProps.onTransitionStart( this.transitionProps, this.prevTransitionProps ); if (result instanceof Promise) { // why do we bother awaiting the result here? await result; } } // jump immediately to the new value indexHasChanged && position.setValue(toValue); // end the transition this.handleTransitionEnd(); }); } else if (isTransitioning) { this.isTransitionRunning = true; this.setState(nextState, async () => { if (nextProps.onTransitionStart) { const result = nextProps.onTransitionStart( this.transitionProps, this.prevTransitionProps ); // Wait for the onTransitionStart to resolve if needed. if (result instanceof Promise) { await result; } } // get the transition spec. const transitionUserSpec = nextProps.configureTransition ? nextProps.configureTransition( this.transitionProps, this.prevTransitionProps ) : null; const transitionSpec = { ...DefaultTransitionSpec, ...transitionUserSpec, }; const { timing } = transitionSpec; delete transitionSpec.timing; // if swiped back, indexHasChanged == true && positionHasChanged == false // @ts-ignore const positionHasChanged = position.__getValue() !== toValue; if (indexHasChanged && positionHasChanged) { timing(position, { ...transitionSpec, toValue: nextProps.navigation.state.index, }).start(() => { // In case the animation is immediately interrupted for some reason, // we move this to the next frame so that onTransitionStart can fire // first (https://github.com/react-navigation/react-navigation/issues/5247) requestAnimationFrame(this.handleTransitionEnd); }); } else { this.handleTransitionEnd(); } }); } } render() { return ( {this.props.render(this.transitionProps, this.prevTransitionProps)} ); } private handleLayout = (event: LayoutChangeEvent) => { const { height, width } = event.nativeEvent.layout; if ( this.state.layout.initWidth === width && this.state.layout.initHeight === height ) { return; } const layout: TransitionerLayout = { ...this.state.layout, initHeight: height, initWidth: width, isMeasured: true, }; layout.height.setValue(height); layout.width.setValue(width); const nextState = { ...this.state, layout, }; this.transitionProps = buildTransitionProps(this.props, nextState); this.setState(nextState); }; private handleTransitionEnd = () => { if (!this.isComponentMounted) { return; } const prevTransitionProps = this.prevTransitionProps; this.prevTransitionProps = undefined; const scenes = filterStale(this.state.scenes); const nextState = { ...this.state, scenes, }; this.transitionProps = buildTransitionProps(this.props, nextState); this.setState(nextState, async () => { if (this.props.onTransitionEnd) { const result = this.props.onTransitionEnd( this.transitionProps, prevTransitionProps ); if (result instanceof Promise) { await result; } } if (this.queuedTransition) { let { prevProps } = this.queuedTransition; this.queuedTransition = null; this.startTransition(prevProps, this.props); } else { this.isTransitionRunning = false; } }); }; } function buildTransitionProps(props: Props, state: State): TransitionProps { const { navigation } = props; const { layout, position, scenes } = state; const scene = scenes.find(isSceneActive); if (!scene) { throw new Error('Could not find active scene'); } return { layout, navigation, position, scenes, scene, index: scene.index, }; } function isSceneNotStale(scene: Scene) { return !scene.isStale; } function filterStale(scenes: Scene[]) { const filtered = scenes.filter(isSceneNotStale); if (filtered.length === scenes.length) { return scenes; } return filtered; } function isSceneActive(scene: Scene) { return scene.isActive; } const styles = StyleSheet.create({ main: { flex: 1, }, }); export default Transitioner;