reactjs / react-transition-group

An easy way to perform animations when a React component enters or leaves the DOM
https://reactcommunity.org/react-transition-group/
Other
10.14k stars 651 forks source link

Example of usage with React Router 4 for page transitions #136

Closed vladshcherbin closed 7 years ago

vladshcherbin commented 7 years ago

The inspiration is from this post - medium post.

tl;dr

Here is the result with React Router 4, CSSTransition and css classes - codesandbox. In order to change page transition you need to replace css properties. No external libraries are needed.


Initial issue:

Here is the result webpage from the article. It is working this way:

Since in v2 the API was changed, I'm trying to create similar transition effect. I do think this example will be helpful for other devs and I would like some help to make it work.

  1. Here is basic setup - https://codesandbox.io/s/W7R5BKzzX. As you can see, the new element is rendered on top of old ones. Next step, we need to make a transition.
  1. Here is an example with fixed position - https://codesandbox.io/s/pYOyyMxPQ. Both, the new and the old elements are rendered in the same spot. Next step is to fade out the old element with 175ms duration and fade in the new element after delay of 250ms.

This is where I've come so far. Ideas and advices would be great - cc @jquense @Horizon-Blue

Update 1:

Switch component is rendering the wrong component on exit because the current location is changed while the element is not removed from DOM yet. The solution is to pass location prop into Switch in order to render the correct component during the whole lifecycle.

Update 2

With CSSTransition and css classes it is relatively easy to create page transition once you understand the way it works. Here is the result - codesandbox.

For my project, this is good enough although it'll be interesting to see transition with external library (GSAP, animejs or another one).

Update 3

Found another bug with history package in RR4. Clicking the same link creates duplicates in location history and navigating back is broken if location.pathname is used as a key (which was supposed to remove nested route animation). history issue, current workaround

jquense commented 7 years ago

So i'm not really familiar with how RR4 works, but to animate the things you can do something like this: https://codesandbox.io/s/wmEJoD5oM I don't know why the "old" route updates showing the same new page twice, but that is down to how RR4 works. I tried to prevent updates on the exiting component but the router is bypassing shouldComponentUpdate higher in the component tree so there isn't much i can do. Maybe others know more

horizon-blue commented 7 years ago

I was facing a similar issue while developing my blog. I ended up using something like this: https://codesandbox.io/s/YE6l8EmR9

A few highlights:

The link above includes more details on how the CSS is implemented. I used a fixed timeout for all transitions and didn't include any delay, but it should be fairly easy to extend on this setup.


Edit: example for custom delay: https://codesandbox.io/s/xvjlnGMnr

vladshcherbin commented 7 years ago

Yes, Switch is rendering the component according to the current location, so when the component is exiting the location is already changed and another element is rendered according to it. So, we can get current location and pass it to Switch so it won't read new location and stay the same for the whole component lifecycle. [Solved]

I'm also using fixed section wrapper.

So, the only remaining thing is transition animation. I'd love to explore two ways:

1) native (with CSSTransition and classes) like in examples above 2) external animation library (I tried Anime, has great docs and is easy to use) and Transition.

The 1 one seems easy, the 2 one can probably be done with onEnter, onExit functions as they return the rendered element, which is what anime wants.

I'll post the result here when I finish.

rhernandog commented 7 years ago

@Horizon-Blue thanks for the sample. I'm a big fan of GreenSock(GSAP), in fact with the lack of simple examples on how to transition routes I was already looking for a way to change the router history using code after an animation was completed. Since GSAP has a great set of callbacks my idea was to make the out animation and when that was completed update the route and create another animation (perhaps on componentDidMount in order to animate in the other component, but with this sample I won't have to do that.

@VladShcherbin addEndListener works with GSAP in a simple component transition. I made this simple transition using GSAP and the <Transition> element, the code is like this:

import React, { Component } from 'react';
import Transition from 'react-transition-group/Transition';

const duration = 5000;

const defaultStyle = {
  opacity: 0,
};

class Fade extends Component {
  constructor(props) {
    super(props);
  }

  render() {
    return (
      <Transition
        in={this.props.in} timeout={duration} mountOnEnter={true} unmountOnExit={true}
        addEndListener={(n, done) => {
          if (this.props.in) {
            TweenLite.to(n, 1, { opacity: 1, onComplete: done });
          } else {
            TweenLite.to(n, 1, { opacity: 0, onComplete: done });
          }
        }}
      >
        { state =>
          <div className="card" style={{ marginTop: '10px', ...defaultStyle }}>
            <div className="card-block">
              <h1 className="text-center">FADE IN/OUT COMPONENT</h1>
            </div>
          </div>
        }
      </Transition>
    );
  }
}

The parent component looks like this:

import React, { Component } from 'react';
import Fade from './children';

class App extends Component {
  constructor() {
    super();
    this.toggleEnterState = this.toggleEnterState.bind(this);
    this.state = { isVisible: false };
  }

  toggleEnterState() {
    const { isVisible } = this.state;
    this.setState({ isVisible: !isVisible });
  }

  render() {
    return (
      <div className="col-12" style={{ marginTop: '10px' }}>
        <button className="btn btn-primary" onClick={this.toggleEnterState}>
          Toggle Component View
        </button>
        <Fade in={this.state.isVisible} />
      </div>
    );
  }
}

In the live sample the duration is way bigger than the animation because if you want to create a timeline that could be longer, the component will be unmounted before the animation is complete.

https://codesandbox.io/s/yvYE9NNW

I haven't been able to make RR animations with GSAP, hopefully this sample could help me in getting it done.

Hopefully there could be some sort of cooperative work between RR4 and RTG in order to generate a consistent and simple way of animating route transitions.

vladshcherbin commented 7 years ago

I tried both CSSTransition and external library (animejs) for transition. With CSSTransition and css classes it's easy to create a transition, while with external library it's a lot harder (you have to deal with incomplete animations and other stuff).

It'll be interesting to see fluid page transition with external libraries (GSAP, animejs, etc). For my project, animating with CSSTransition is good enough.

Here is the result, there are simple (animated) and nested (non-animated) routes.

I'm closing the issue since it's solved for me, feel free to re-open it or continue discussion. Thank you for your help and ideas, @jquense @Horizon-Blue @rhernandog ! ❤️

rhernandog commented 7 years ago

@VladShcherbin Just completed a sample using GSAP, RTG and RR:

https://codesandbox.io/s/mQy3mMznn

At the end I couldn't use addEndListener so I ended up using onEnter and onExit in order to make it work. Also to prevent incomplete animations the timeout prop passed to the <Transition> element has to be equal to the animation duration defined in the GSAP instance so the animation can be completed before the element is unmounted from the DOM. Be aware that GSAP uses seconds and RTG milliseconds so be sure to make the conversion.

Also there are a few hacks that seem to be inevitable, such as setting the display to fixed and because of that I implemented a way to keep the width of the element equal to the parent's width and using clearProps in the onComplete callback, remove the style so the element can go back to it's natural size and normal position value, if the screen is resized. Still is a lot of work but it gets the job done :wrench:

vladshcherbin commented 7 years ago

@rhernandog in your example, if you navigate between routes too fast, the components will be rendered on top of each other.

rhernandog commented 7 years ago

@VladShcherbin That can be expected, since the position:fixed is removed when the animations are completed. You could create a boolean to prevent any other click event until the animation is completed.

My main concern is keeping the elements with position fixed permanently. This should be considered just as a proof of concept, it needs a lot of work, that's for sure.

Another possibility is not animate the element onExit, that would prevent the overlapping issue.

rhernandog commented 7 years ago

@VladShcherbin I was playing with the sample and the issue is that if you click between routes too quick, the DOM element is not unmounted when a new animation is triggered. GSAP has a feature called overwrite manager that basically cancels an existing animation if a new one that affects the same DOM element and properties is created. In this case the previous instance that animates opacity and the x position is still running when a new one is created. The current active one is killed and sent to GC and the new one takes control over the DOM element.

Another choice could be use a fromTo instance which forces the initial values of the element and then animates it to the final values, but that could create a lot of jumps in the animation, which ultimately is going to hurt the experience. I'd prefer creating a boolean and preventing any click event from changing the route.

vladshcherbin commented 7 years ago

@jquense @Horizon-Blue @rhernandog I found another unexpected bug 😞

Try clicking the same link many times and navigate back. The animation logic will be broken. This is because history package in RR4 duplicates the same link in location history. It was working in RR3 though. I opened an issue for this.

Same issue exists here - http://animate.mhaagens.me. Try clicking the same link many times and navigate back. It will loop through duplicated locations and trigger animations every time.

Since we tried to remove nested route component re-animating with location.pathname as a key, it's not working correctly for us. When you go back through duplicated locations, our component won't have any animations because location.pathname is the same.

rhernandog commented 7 years ago

@VladShcherbin I see you landed on the issue I created some time ago :wink:

The solution I ended up using, that requires less effort than check the location key is to use hash history instead of browser history as the main router tag:

import {HashRouter as Router, Route} from "react-router-dom";

// then use it
const App = () => 
  <Router>
    // rest of your JSX
  </Router>;
vladshcherbin commented 7 years ago

@rhernandog yes, but this is HashRouter, I don't want hashes in my urls 😉

Oh, rollback to using location.key as a key breaks nested routes like /articles/nested because on every route change the top component will be re-animated which is not desired. This has to be solved somehow.

@Horizon-Blue any ideas?

horizon-blue commented 7 years ago

@VladShcherbin How about this one: https://codesandbox.io/s/8nBON3Ej

As a workaround, I defined a custom "SafeLink" wrapper that does the checking for Link, which replaces (instead of pushes) the history when the current pathname is the same as the target. (The rule is controlled by the replace props of Link.)

vladshcherbin commented 7 years ago

@Horizon-Blue yeah, this seems like a nice workaround for now. It was actually working in RR v3 and history v3, but was changed in RR v4 and history v4.

We can track this issues (and related ones) to remove it later:

https://github.com/ReactTraining/history/issues/470, https://github.com/ReactTraining/history/issues/507

bzin commented 6 years ago

@VladShcherbin @Horizon-Blue @rhernandog First of all, I want to thank you guys because with this issue discussion I was able to understand a little bit more about the new React Transition Group.

However, I am still scratching my head on the onEnter, and onExit methods since these are not working for me and once fired both always trigger the same component instead of the one that is entering and the one that is leaving.

const currentKey = location.pathname.split('/')[1] || '/'
const timeout = { enter: 300, exit: 200 }
<TransitionGroup>
    <Transition 
      key={currentKey} 
      timeout={timeout} 
      mountOnEnter={true} 
      unmountOnExit={true} 
      onEnter={(node)=> { console.log('enter', node); }} 
      onExit={(node) => { console.log('exit', node); }} >

        <Switch location={location}>

            <Route exact path="/cases/:caseURL" render={(props)=> { 
              return (!this.props.state.intro) ? (
                <Case {...props} cases={this.props.state.cases} unlockBody={this.props.unlockBody} lockBody={this.props.lockBody} addCaseToState={this.props.addCaseToState}/>
              ) : null; }}/>

            <Route exact path="/studio" render={(props)=> {
             return (!this.props.state.intro) ? (
                  <Studio data={this.props.state.studio} {...props} />
              ) : null; }}/>

            <Route exact path="/contact" render={(props)=> { return (!this.props.state.intro) ? (
                  <Contact data={this.props.state.contact} {...props} />
              ) : null; }}/>

        </Switch>

    </Transition>
</TransitionGroup>

My logging as soon as contact route is triggered: screen shot 2017-11-02 at 11 58 35

horizon-blue commented 6 years ago

Hi, @bzin , can you verify that your location is not undefined? I just try your setup, and the same problem occurs when I forgot to specify what is the location.

You could take a look that this: https://codesandbox.io/s/k52zro4k3o . It is a little bit messy, but I hope it could give you a sense of what I am talking about.

bzin commented 6 years ago

@Horizon-Blue thanks a lot!

That was it! I was passing the window.location instead of a location returned by the Route.

In my setup I did not add a first <Route path="/" component={App} />. I was doing everything in a Routing component I created.

But now with this I am wondering how can we have different timeouts for different routes. Is this possible?

horizon-blue commented 6 years ago

@bzin How about the method I mention here: https://github.com/ReactTraining/react-router/issues/5279#issuecomment-316877263 ?

LKay commented 6 years ago

I made a different kind of wrapper based on the original TransitionGroup component to handle replacing one child with another mainly for transitioning between routes in mind. I also included a SwitchTransition component for easy react-router integration so you can use Transition or CSSTransition components to handle your animations. Check it out here: https://github.com/LKay/react-transition-replace Working examples are in the docs and storybook.

HenrikBechmann commented 6 years ago

FWIW I'm using react-router 4 and react-router-redux

    render() {
        let location = this.props.router.location || {}
        return (
        <ConnectedRouter history = {this.props.history}>
            <TransitionGroup>
                <CSSTransition
                    key = {location.key}
                    classNames="fade"
                    timeout={2000}
                    appear= {true}
                    exit={false}
                    onEnter = {() => {
                        window.scrollTo(0, 0)
                    }}
                >
                    <Switch location = {location}>
                        { routes }
                    </Switch>
                </CSSTransition>
            </TransitionGroup>
        </ConnectedRouter>
        )
    }
}

let mapStateToProps = state => {
    let { router } = state
    return { 
        router,
    }
}

Routes = connect(mapStateToProps)(Routes)

export { Routes }

and here's the CSS:

.fade-appear,
.fade-enter {
    opacity:0;
}

.fade-appear-active,
.fade-enter-active {
    opacity:1;
    transition: opacity 1.5s;
}
totodot commented 6 years ago

Do you know how to avoid rerender child component? Here I have an example https://codesandbox.io/s/jv98w12vo3 Home component renders 4 times on entering and 3 times on leave despite I use PureComponent.

HenrikBechmann commented 6 years ago

Try shouldComponentUpdate()

Also, investigate optimization: https://reactjs.org/docs/optimizing-performance.html

afholderman commented 6 years ago

I'm still running into the re-render issue in my project. A stripped down version of the code is below. We're creating an on-boarding wizard, hence the 'knownSteps" components. The re-render problem occurs when navigating between all routes, not just the steps, but I left it in there to better reflect the actual code.

const knownSteps = [
  'step1',
  'step2'
];

export const routes = (
  <Route
    path="/"
    render={({ location }) => (
      <Layout>
        <TransitionGroup>
          <CSSTransition key={location.key} classNames="fade" timeout={1000}>
            <Switch location={location}>
              <Route exact={true} path="/" component={Home} />
              {knownSteps.map(s => (
                <Route key={s} path={`/${s}`} component={ContentManager} />
              ))}
              <Route path="/500" component={ServerError} />
              <Route component={NotFound} />
            </Switch>
          </CSSTransition>
        </TransitionGroup>
      </Layout>
    )}
  />
);
HenrikBechmann commented 6 years ago

fwiw I ended up not using TransitionGroup and CSSTransition; that at least suppressed visible effects.

richmeij commented 6 years ago

@totodot @afholderman Did you try using render instead of component? For example:

<Route exact path="/" render={() => <Home />}

https://reacttraining.com/react-router/web/api/Route/render-func

HenrikBechmann commented 6 years ago

No I didn't. Thanks for the tip. This looks like an excellent resource!

afholderman commented 6 years ago

@richmeij My issue ended up being related to how my components were accessing redux store data further down the chain. Freezing them in shouldComponentUpdate did the trick for me.

silvenon commented 6 years ago

Just a follow-up to this discussion. FWIW, I added a piece of the documentation accompanied by a demo about usage with React Router. It will be deployed in the next release.

Basically you shouldn't wrap Route or Switch components with a TransitionGroup, you should use CSSTransition at the end of each route, like in the demo.

DanielGibbsNZ commented 6 years ago

@silvenon Your documentation is really useful, thanks! However I'm getting errors with my default route (Warning: You tried to redirect to the same route you're currently on: "/users"). Where I previously had (without transitions):

<Switch>
  <Route path="/users">
    <UsersPage />
  </Route>
  // More routes.
  <Route render={() => (
    <Redirect to="/users" />
  )}></Route>
</Switch>

I now have (following the example in your documentation):

<Route path="/users">
  {({ match }) => (
    <CSSTransition in={match != null} timeout={500} classNames="fade" mountOnEnter={true} unmountOnExit={true}>
      <UsersPage />
    </CSSTransition>
  )}
</Route>
// More routes...
<Route render={() => (
  <Redirect to="/users" />
)}></Route>

Obviously, because the <Switch> is gone, the redirect is being rendered every time, causing the above warning message. How can I only get the redirect to render if none of the other routes are matched without using a <Switch>?

rhernandog commented 6 years ago

@DanielGibbsNZ An option could be to create an array of the paths and routes, just like @silvenon sample. With that you can check if the current location doesn't match any of them. You could use Lodash findIndex method:

https://lodash.com/docs/4.17.10#findIndex

Something like this:

// routes array
const routes = [
  { path: '/', name: 'Home', Component: Home },
  { path: '/about', name: 'About', Component: About },
  { path: '/contact', name: 'Contact', Component: Contact },
];

// then inside your router add the following  for the specific redirect
<Route path="/" render={({location}) => {
  if( _.findIndex(routes, [path, location.pathname]) < 0){
    return <Redirect to="/users" />;
  } else {
    return "";
  }
}}

Maybe there's a better alternative, this is what comes to my mind now, hope it helps.