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.18k stars 649 forks source link

Let the child decide when to switch to the next transition stage #400

Open mvasin opened 6 years ago

mvasin commented 6 years ago

Do you want to request a feature or report a bug? A feature.

What is the current and the expected behavior? Let's imagine that <Transition> renders a child component, and the child component knows better when it's done transitioning (i.e. uses onComplete GSAP event triggered at the end of animation timeline). It has nothing to do with listening to a CSS transitionend event on a particular DOM node, because transition in the child component can involve multiple DOM nodes.

Imagine a dialog: <Transition>: Get ready for your new job, start entering! <Child>: OK, I'll unpack my belongings and will tell when I'm ready to proceed. <Child>: I'm done! <Transition>: Good, you are in entered status. ... <Transition>: It's time for you to quit. Take you time as you are exiting, I'll wait as long as you need. <Child>: Thanks for waiting, I packed my stuff and ready for departure. <Transition>: Great, now I kick you off. You're exited!

At the moment the react-transition-group API does not allow such a dialog.

One option we have to set timeout and try to match it with <Child>'s transition end time, something that's error-prone and not always possible (think spring animations).

The other option is to assign transition end listener function to addEndListener prop. But the problem here is passing done callback from the end listener function to the Child component. addEndListener={(_, done) => transitionIsEnded && done()} also doesn't work because the function is triggered only once, while transitionIsEnded is false. And we can't save done to state because render() must be idempotent.

I'm still exploring how to tweak the API to allow the dialog above. One idea is to provide <Transition> with transitionIsEnded boolean prop. If it's true, <Transition> will switch to the next stage (entering -> entered or exiting -> exited). If it's false, it will wait and switch on any of the following conditions:

It implies there is an upper component that holds transitionIsEnded in state and gives a function to the <Child> component to toggle that state.

If you have any thoughts on this, please share.

mvasin commented 6 years ago

The existing enter and exit props could be used instead of transitionIsEnded. So looks like the API change is again not needed. But <Transition> doesn't listen for changes of those props, this must be fixed.

jquense commented 6 years ago

I'm not sure i understand where the current limitation is, why can't you pass a callback donw to the child component it can call to toggle in?

mvasin commented 6 years ago

@jquense done is only available in addEndListener function:

<Transition
  addEndListener={(node, done) => /* you can use `done` here */ }
>
  <Child setDone={done /* but `done` is not available here */ } />
</Transition>

How you can pull done out of addEndListener? You can save it to state of an outer component, but that will make the render function impure; that's a big red flag.

What we need is a prop coming from outer component that shortcuts any transitions <Transition> thinks are running. And that shortcut is activated by <Child>. done function belongs to the outer component and triggers its state.

class Wrapper extends React.Component {
  state = {isDone: false}
  setDone = this.setState({isDone: true})
  setUndone = this.setState({isDone: false})

  render() {
    return (
      <Route path='/some-path'>
        ({match}) => (
          <Transition
            in={!!match}
            mountOnEnter
            unmountOnExit
            appear={this.state.isDone}
            enter={this.state.isDone}
            exit={this.state.isDone}
            onEntered={this.setUndone}
            onExited={this.setUndone}
          >
            {transitionStage => <Child transitionStage={transitionStage} setDone={this.setDone} />}
          </Transition>
        )
      </Route>
    )
  }
}

As soon as Child is done, it triggers setDone of the outer component, and state.done from the outer component informs <Transition> via enter / exit props it should be done transitioning.

Looking at the docs, it could work like this, but it doesn't; setting enter or exitto false in the middle of a transition doesn't end the transition.

mvasin commented 6 years ago

One more thing to consider is appear enter={false} behaviour. Those are somewhat contradictory options.

I'd prefer enter to take precedence over appear and disable all entering transitions including the first appearance, so in the case above enter={this.state.isDone} would be enough. Currently it doesn't take precedence, and we need to set appear={this.state.isDone} as well (and watch for appear prop changes in the <Transition> code).

But we can get along with it for now and maybe fix it in another PR.

jquense commented 6 years ago

I don't really understand the use-case here practically so i'm a bit reticent to extend or change the public API to allow for it. Generally cases like your above are handling by composing Transition and Child together into TransitioningChild, where the controls state is moved up to the parent, not down. Passing through the stage and done feels like a leady encapsulation to me, but i don't have all the details.

If we are going to add something like this the right API is to pass done to the child via the renderProp.

mvasin commented 6 years ago

I would love to control the state up the tree instead of in the child, and that's what I tried in the first place, but there is a GSAP gotcha: is must know the exact DOM nodes as it declares the timeline. So timeline declaration is tied to the child, as DOM nodes are known only after component had been mounted. Weird, but if you declare GSAP timeline using ids or classes while DOM nodes didn't yet exist, it's not gonna work even you will mount components with those ids/classes later, and after that will play the timeline. Seems like an optimisation on GSAP side, to find the nodes once and never consider searching the DOM again.

That's why all I've left to do is to provide the done callback to the child. It will be used on the GSAP timeline by the end of an animation.

Adding done as the second parameter is a good idea as well, I'd be quite happy with that.