import * as React from "react"; import { View } from "react-native"; import { append, compose, identity, tail, take, update } from "ramda"; import { AnimationManager } from "./AnimationManager"; import { Scene } from "./Scene"; import { getSceneByRoute, shouldSkipAnimationForPreviousWebViewScene, } from "./utils"; import { v4 as uuid_v4 } from "uuid"; import { isScreenActiveForRoute } from "@applicaster/zapp-react-native-utils/reactHooks/navigation/useIsScreenActive"; export const NAV_ACTION_PUSH = "PUSH"; export const NAV_ACTION_REPLACE = "REPLACE"; export const NAV_ACTION_BACK = "POP"; type SceneProps = { children: React.ReactNode; overlayStyle: any; style: any; animating: boolean; }; /** * This function returns an object with the children and the pathname * used as key. this prevents scenes unmounting when transitions occur * @param {Object} children * @returns { children: Object, key: string } */ function prepareScene(children) { return { children, key: children.props.pathname, screenUniqueId: uuid_v4(), }; } /** * 1. If there is more than one scene, get back to the previous scene. * 2. If there is no scene to go back to, clone the new children as the scene to go back to. * @param {any} props Component's props * @param {any} state Component's state */ function scenesForBackTransition(props: SceneProps, { scenes }) { return scenes.length > 1 ? identity(scenes) : [prepareScene(props.children), scenes[0]]; } type Props = { transitionConfig: any; children: React.ReactNode; navigator: { previousAction: string; currentRoute: string; videoModalState?: { visible?: boolean; mode?: string; }; screenData: { [key: string]: any; }; }; contentStyle: { [key: string]: any; }; plugins: any; layoutData: { isTabletPortrait: boolean; isTablet: boolean; width: number; height: number; }; }; type State = { animatedValue: any; animating: boolean; duration: number; easing: any; from: any; to: any; scenes: any; }; const styles = { container: { flex: 1, overflow: "hidden" }, } as const; export class TransitionerComponent extends React.PureComponent { constructor(props: Props) { super(props); this.animationManager = new AnimationManager(props); this.state = { scenes: [prepareScene(props.children)], // starting with index: 0 ...this.animationManager.initialState(), }; this.pushTransitionStart = this.pushTransitionStart.bind(this); this.pushTransitionEnd = this.pushTransitionEnd.bind(this); this.backTransitionStart = this.backTransitionStart.bind(this); this.backTransitionEnd = this.backTransitionEnd.bind(this); } /** * Clone new children, append to scenes array, * and trigger push animation with callbacks. */ pushTransitionStart() { this.setState( { scenes: append(prepareScene(this.props.children), this.state.scenes), ...this.animationManager.stateForAction(this.state, NAV_ACTION_PUSH), }, () => { this.animationManager.animate(this.state, this.pushTransitionEnd); } ); } /** * Trigger back animation. * The last scene in the array is removed in the "backTransitionEnd" callback, * only after the animation is complete. */ backTransitionStart() { const previousRoute = this.props.navigator?.currentRoute; const currentRoute = this.props.navigator?.currentRoute; const diffLengthRoutes = (previousRoute?.match(/river\//g) || []).length - (currentRoute?.match(/river\//g) || []).length; const stepsBack = diffLengthRoutes && diffLengthRoutes > 0 ? diffLengthRoutes : 1; this.setState( { scenes: scenesForBackTransition(this.props, this.state), ...this.animationManager.stateForAction( this.state, NAV_ACTION_BACK, stepsBack ), }, () => { this.animationManager.animate(this.state, () => this.backTransitionEnd(stepsBack) ); } ); } shouldSkipAnimationForRoute(route) { // We are skipping animations for some routes, likes hooks & the player mainly return route.includes("/playable") || route.includes("/hook"); } shouldResetStateForRoute(route) { // We are resetting the transitioner state for root routes return (route?.match(/river\//g) || []).length === 1; } transitionWithoutAnimation(resetState) { const { scenes } = this.state; const newScene = prepareScene(this.props.children); const isLastSceneHook = scenes[scenes.length - 1].key.startsWith("/hook"); let newState; if (resetState) { newState = { scenes: [newScene], to: { index: 0 }, }; } else if (isLastSceneHook || scenes.length === 1) { newState = { scenes: update(scenes.length - 1, newScene, scenes) }; } else { newState = { scenes: compose(tail, append(newScene))(scenes), }; } this.setState(newState); } componentDidUpdate(prevProps: Props) { const currentRoute = this.props.navigator?.currentRoute; const previousRoute = prevProps.navigator?.currentRoute; const isGoingBack = this.props.navigator.previousAction === NAV_ACTION_BACK; if (currentRoute !== previousRoute) { if (this.state.animating && !isGoingBack) { // this happens when a new transition is triggered before the previous one ended // Instant modification of active scene without animations. return this.transitionWithoutAnimation(); } // Skipping animation when coming back from the 'landscape' WebView component" if ( isGoingBack && shouldSkipAnimationForPreviousWebViewScene({ previousScene: getSceneByRoute({ route: previousRoute, scenes: this.state.scenes, }), nextScene: getSceneByRoute({ route: currentRoute, scenes: this.state.scenes, }), layoutData: this.props.layoutData, }) ) { return this.transitionWithoutAnimation(); } // TODO: Add explicit check for differnt orientation and skip animated transition in that case. // Now it checks for playable and hooks, but in theory we can have non-player screens // with different orientation if ( this.shouldSkipAnimationForRoute(previousRoute) || this.shouldSkipAnimationForRoute(currentRoute) ) { // we are going back, in this case we need to have only one scene using resetState const resetState = isGoingBack ? true : undefined; return this.transitionWithoutAnimation(resetState); } switch (this.props.navigator.previousAction) { case NAV_ACTION_PUSH: this.pushTransitionStart(); break; case NAV_ACTION_BACK: { this.backTransitionStart(); break; } case NAV_ACTION_REPLACE: { // Instant modification of active scene without animations. this.transitionWithoutAnimation( this.shouldResetStateForRoute(currentRoute) ); break; } default: // Should never get here - disabling animation just to be on the safe side. this.setState({ animating: false }); break; } } } // Sets state.animating to false pushTransitionEnd() { this.setState({ animating: false, }); } // Sets state.animating to false // and remove last scene from scenes stack backTransitionEnd(stepsBack) { this.setState({ animating: false, scenes: take(this.state.scenes.length - stepsBack, this.state.scenes), }); } // On initial state, both "to" and "from" point to a single scene // so only a single scene should be rendered, // instead of dangerous nulls or duplicates. renderSingleScene({ scenes, to }) { const { contentStyle, navigator, children } = this.props; const { currentRoute, videoModalState } = navigator; const screenData = children.props.screenData; const pathname = scenes[to.index].key; return ( {scenes[to.index] ? ( {scenes[to.index].children} ) : null} ); } // Render two scenes consecutively - // the scenes' indexes determines which scene should be drawn first. // Looks redundant, but when written like this // we do not need to pass or generate keys. renderScenes({ scenes, from, to }) { const { contentStyle, navigator } = this.props; const { currentRoute, videoModalState } = navigator; const fromScene = scenes[from.index] && ( {scenes[from.index].children} ); const toScene = scenes[to.index] && ( {scenes[to.index].children} ); return from.index <= to.index ? ( {fromScene} {toScene} ) : ( {toScene} {fromScene} ); } render() { const { from, to } = this.state; if (from.index === to.index) { return this.renderSingleScene(this.state); } return this.renderScenes(this.state); } }