React Router Transition In And Out Events
Solution 1:
So it turns out that an approach that supports restarting the entering transition if you switch from route 1 to route 2 and then back to route 1 while route 1 is still exiting is pretty tricky. There may be some minor issues in what I have, but I think the overall approach is sound.
The overall approach involves separating out a target path (where the user wants to go) from the rendering path (that path currently displayed which may be in a transitioning state). In order to make sure a transition occurs at the appropriate times, state is used to sequence things step by step (e.g. first render a transition with in=false
, then render with in=true
for an entering transition). The bulk of the complexity is handled within TransitionManager.js
.
I've used hooks in my code because it was easier for me to work through the logic without the syntax overhead of classes, so for the next couple months or so, this will only work with the alpha. If the hooks implementation changes in the official release in any way that breaks this code, I will update this answer at that time.
Here's the code:
index.js
importReactfrom"react";
importReactDOM from"react-dom";
importAppfrom"./App";
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
App.js
importReactfrom"react";
import { BrowserRouter } from"react-router-dom";
importLinkOrStaticfrom"./LinkOrStatic";
import { componentInfoArray } from"./components";
import {
useTransitionContextState,
TransitionContext
} from"./TransitionContext";
importTransitionRoutefrom"./TransitionRoute";
constApp = props => {
const transitionContext = useTransitionContextState();
return (
<TransitionContext.Providervalue={transitionContext}><BrowserRouter><div><br />
{componentInfoArray.map(compInfo => (
<LinkOrStatickey={compInfo.path}to={compInfo.path}>
{compInfo.linkText}
</LinkOrStatic>
))}
{componentInfoArray.map(compInfo => (
<TransitionRoutekey={compInfo.path}path={compInfo.path}exactcomponent={compInfo.component}
/>
))}
</div></BrowserRouter></TransitionContext.Provider>
);
};
exportdefaultApp;
TransitionContext.js
importReact, { useState } from"react";
exportconstTransitionContext = React.createContext();
exportconstuseTransitionContextState = () => {
// The path most recently requested by the userconst [targetPath, setTargetPath] = useState(null);
// The path currently rendered. If different than the target path,// then probably in the middle of a transition.const [renderInfo, setRenderInfo] = useState(null);
const [exitTimelineAndDone, setExitTimelineAndDone] = useState({});
const transitionContext = {
targetPath,
setTargetPath,
renderInfo,
setRenderInfo,
exitTimelineAndDone,
setExitTimelineAndDone
};
return transitionContext;
};
components.js
importReactfrom"react";
constHome = props => {
return<div>Hello {props.state + " Home!"}</div>;
};
constProjectOne = props => {
return<div>Hello {props.state + " Project One!"}</div>;
};
constProjectTwo = props => {
return<div>Hello {props.state + " Project Two!"}</div>;
};
exportconst componentInfoArray = [
{
linkText: "Home",
component: Home,
path: "/"
},
{
linkText: "Show project one",
component: ProjectOne,
path: "/projects/one"
},
{
linkText: "Show project two",
component: ProjectTwo,
path: "/projects/two"
}
];
LinkOrStatic.js
importReactfrom"react";
import { Route, Link } from"react-router-dom";
constLinkOrStatic = props => {
const path = props.to;
return (
<><Routeexactpath={path}>
{({ match }) => {
if (match) {
return props.children;
}
return (
<LinkclassName={props.className}to={props.to}>
{props.children}
</Link>
);
}}
</Route><br /></>
);
};
exportdefaultLinkOrStatic;
TransitionRoute.js
importReactfrom"react";
import { Route } from"react-router-dom";
importTransitionManagerfrom"./TransitionManager";
constTransitionRoute = props => {
return (
<Routepath={props.path}exact>
{({ match }) => {
return (
<TransitionManagerkey={props.path}path={props.path}component={props.component}match={match}
/>
);
}}
</Route>
);
};
exportdefaultTransitionRoute;
TransitionManager.js
importReact, { useContext, useEffect } from"react";
import { Transition } from"react-transition-group";
import {
slowFadeInAndDropFromAboveThenLeftRight,
slowFadeOutAndDrop
} from"./animations";
import { TransitionContext } from"./TransitionContext";
constNEW_TARGET = "NEW_TARGET";
constNEW_TARGET_MATCHES_EXITING_PATH = "NEW_TARGET_MATCHES_EXITING_PATH";
constFIRST_TARGET_NOT_RENDERED = "FIRST_TARGET_NOT_RENDERED";
constTARGET_NOT_RENDERED_AND_RENDER_PATH_EXITED =
"TARGET_NOT_RENDERED_AND_RENDER_PATH_EXITED";
constTARGET_NOT_RENDERED_AND_RENDER_PATH_EXITING =
"TARGET_NOT_RENDERED_AND_RENDER_PATH_EXITING";
constTARGET_RENDERED = "TARGET_RENDERED";
constNOT_TARGET_AND_NEED_TO_START_EXITING =
"NOT_TARGET_AND_NEED_TO_START_EXITING";
constNOT_TARGET_AND_EXITING = "NOT_TARGET_AND_EXITING";
constNOT_TARGET = "NOT_TARGET";
constusePathTransitionCase = (path, match) => {
const {
targetPath,
setTargetPath,
renderInfo,
setRenderInfo,
exitTimelineAndDone,
setExitTimelineAndDone
} = useContext(TransitionContext);
let pathTransitionCase = null;
if (match) {
if (targetPath !== path) {
if (
renderInfo &&
renderInfo.path === path &&
renderInfo.transitionState === "exiting" &&
exitTimelineAndDone.timeline
) {
pathTransitionCase = NEW_TARGET_MATCHES_EXITING_PATH;
} else {
pathTransitionCase = NEW_TARGET;
}
} elseif (renderInfo === null) {
pathTransitionCase = FIRST_TARGET_NOT_RENDERED;
} elseif (renderInfo.path !== path) {
if (renderInfo.transitionState === "exited") {
pathTransitionCase = TARGET_NOT_RENDERED_AND_RENDER_PATH_EXITED;
} else {
pathTransitionCase = TARGET_NOT_RENDERED_AND_RENDER_PATH_EXITING;
}
} else {
pathTransitionCase = TARGET_RENDERED;
}
} else {
if (renderInfo !== null && renderInfo.path === path) {
if (
renderInfo.transitionState !== "exiting" &&
renderInfo.transitionState !== "exited"
) {
pathTransitionCase = NOT_TARGET_AND_NEED_TO_START_EXITING;
} else {
pathTransitionCase = NOT_TARGET_AND_EXITING;
}
} else {
pathTransitionCase = NOT_TARGET;
}
}
useEffect(() => {
switch (pathTransitionCase) {
caseNEW_TARGET_MATCHES_EXITING_PATH:
exitTimelineAndDone.timeline.kill();
exitTimelineAndDone.done();
setExitTimelineAndDone({});
// Making it look like we exited some other path, in// order to restart the transition into this path.setRenderInfo({
path: path + "-exited",
transitionState: "exited"
});
setTargetPath(path);
break;
caseNEW_TARGET:
setTargetPath(path);
break;
caseFIRST_TARGET_NOT_RENDERED:
setRenderInfo({ path: path });
break;
caseTARGET_NOT_RENDERED_AND_RENDER_PATH_EXITED:
setRenderInfo({ path: path, transitionState: "entering" });
break;
caseNOT_TARGET_AND_NEED_TO_START_EXITING:
setRenderInfo({ ...renderInfo, transitionState: "exiting" });
break;
// case TARGET_NOT_RENDERED_AND_RENDER_PATH_EXITING:// case NOT_TARGET:default:
// no-op
}
});
return {
renderInfo,
setRenderInfo,
setExitTimelineAndDone,
pathTransitionCase
};
};
constTransitionManager = props => {
const {
renderInfo,
setRenderInfo,
setExitTimelineAndDone,
pathTransitionCase
} = usePathTransitionCase(props.path, props.match);
constgetEnterTransition = show => (
<Transitionkey={props.path}addEndListener={slowFadeInAndDropFromAboveThenLeftRight()}in={show}unmountOnExit={true}
>
{state => {
const Child = props.component;
console.log(props.path + ": " + state);
return <Childstate={state} />;
}}
</Transition>
);
constgetExitTransition = () => {
return (
<Transitionkey={props.path}addEndListener={slowFadeOutAndDrop(setExitTimelineAndDone)}in={false}onExited={() =>
setRenderInfo({ ...renderInfo, transitionState: "exited" })
}
unmountOnExit={true}
>
{state => {
const Child = props.component;
console.log(props.path + ": " + state);
return <Childstate={state} />;
}}
</Transition>
);
};
switch (pathTransitionCase) {
caseNEW_TARGET_MATCHES_EXITING_PATH:
caseNEW_TARGET:
caseFIRST_TARGET_NOT_RENDERED:
caseTARGET_NOT_RENDERED_AND_RENDER_PATH_EXITING:
returnnull;
caseTARGET_NOT_RENDERED_AND_RENDER_PATH_EXITED:
returngetEnterTransition(false);
caseTARGET_RENDERED:
returngetEnterTransition(true);
caseNOT_TARGET_AND_NEED_TO_START_EXITING:
caseNOT_TARGET_AND_EXITING:
returngetExitTransition();
// case NOT_TARGET:default:
returnnull;
}
};
exportdefaultTransitionManager;
animations.js
import { TimelineMax } from"gsap";
const startStyle = { autoAlpha: 0, y: -50 };
exportconstslowFadeInAndDropFromAboveThenLeftRight = trackTimelineAndDone => (
node,
done
) => {
const timeline = newTimelineMax();
if (trackTimelineAndDone) {
trackTimelineAndDone({ timeline, done });
}
timeline.set(node, startStyle);
timeline
.to(node, 0.5, {
autoAlpha: 1,
y: 0
})
.to(node, 0.5, { x: -25 })
.to(node, 0.5, {
x: 0,
onComplete: done
});
};
exportconstslowFadeOutAndDrop = trackTimelineAndDone => (node, done) => {
const timeline = newTimelineMax();
if (trackTimelineAndDone) {
trackTimelineAndDone({ timeline, done });
}
timeline.to(node, 2, {
autoAlpha: 0,
y: 100,
onComplete: done
});
};
Solution 2:
I've left this answer in place so that the comments still make sense and the evolution is visible, but this has been superseded by my new answer
Here are a few related references which I expect you have looked at some of:
https://reacttraining.com/react-router/web/api/Route/children-func
https://reactcommunity.org/react-transition-group/transition
code sandbox with the code included below, so you can quickly see the effect
The code below uses Transition within a Route using the addEndListener
property to plug in the custom animation using gsap. There are a couple important aspects for making this work. In order for Transition
to go through the entering
state, the in
property must go from false
to true
. If it starts at true
, then it will skip immediately to the entered
state without a transition. In order for this to occur within a Route
, you need to use the children
property of the Route (rather than component
or render
) since then the children will be rendered regardless of whether the Route matches. In the example below you will see:
<Route exact path="/projects/one">
{({ match }) =><Projectsshow={match !== null} />}
</Route>
This passes a boolean show
property to the component which will only be true if the Route matches. This will then be passed as the in
property of the Transition
. This allows Projects
to start with in={false}
, rather than (when using the Route component
property) starting out as not rendered at all (which would prevent a transition from occurring because it would then have in={true}
when it is first rendered).
I didn't fully digest all of what you were trying to do in componentDidMount
of Projects (my example is significantly simplified, but does do a multi-step gsap animation), but I think you will be better off to use Transition
to control the triggering of all your animations rather than trying to use both Transition
and componentDidMount
.
Here's the 1st code version:
importReactfrom"react";
importReactDOM from"react-dom";
import { Transition } from"react-transition-group";
import { BrowserRouter, Route, Link } from"react-router-dom";
import { TweenLite, TimelineMax } from"gsap";
const startState = { autoAlpha: 0, y: -50 };
constonEnter = node => TweenLite.set(node, startState);
constaddEndListener = props => (node, done) => {
const timeline = newTimelineMax();
if (props.show) {
timeline
.to(node, 0.5, {
autoAlpha: 1,
y: 0
})
.to(node, 0.5, { x: -25 })
.to(node, 0.5, {
x: 0,
onComplete: done
});
} else {
timeline.to(node, 0.5, {
autoAlpha: 0,
y: 50,
onComplete: done
});
}
};
constHome = props => {
return (
<TransitionunmountOnExitin={props.show}onEnter={onEnter}addEndListener={addEndListener(props)}
>
{state => {
return <div>Hello {state + " Home!"}</div>;
}}
</Transition>
);
};
constProjects = props => {
return (
<TransitionunmountOnExitin={props.show}onEnter={onEnter}addEndListener={addEndListener(props)}
>
{state => {
return <div>Hello {state + " Projects!"}</div>;
}}
</Transition>
);
};
constApp = props => {
return (
<BrowserRouter><div><br /><Linkto="/">Home</Link><br /><Linkto="/projects/one">Show project</Link><br /><Routeexactpath="/">
{({ match }) => <Homeshow={match !== null} />}
</Route><Routeexactpath="/projects/one">
{({ match }) => <Projectsshow={match !== null} />}
</Route></div></BrowserRouter>
);
};
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
Update 1: Addressing question #1 in your update. One nice thing with react-router version 4 is that routes can appear in multiple places and control multiple parts of the page. In this code sandbox, I've done an update of your code sandbox to have the Home link switch between a Link and static text (though you could change this to use styling such that the look is the same for both). I replaced the Link with LinkOrStaticText (I did this quickly and it could use some refining to handle passing props through more robustly):
constLinkOrStatic = props => {
const path = props.to;
return (
<Routeexactpath={path}>
{({ match }) => {
if (match) {
return props.children;
}
return (
<LinkclassName={props.className}to={props.to}>
{props.children}
</Link>
);
}}
</Route>
);
};
I'll do a separate update to address question 2.
Update 2: In trying to address question 2, I discovered some fundamental issues with the approach I was using in this answer. The behavior was getting muddled because of multiple routes executing at once in certain cases and issues from odd remnants of unmounted transitions that were in-progress. I needed to start over largely from scratch with a different approach, so I'm posting the revised approach in a separate answer.
Post a Comment for "React Router Transition In And Out Events"