React/Redux animations based on actions

2019-07-04 07:18发布

问题:

Having more or less completed my first React+Redux application, I have come to the point where I would like to apply animations to various parts of the application. Looking at existing solutions, I find that there is nothing even close to what I would like. The whole ReactCSSTransitionGroup seems to be both an intrusive and naive way to handle animations. Animation concerns bleed out of the component you want to animate and you have no way to know anything about what happens in the application. From my initial analysis I have come up with the following requirements for what I would consider a good animation API:

  • The parent component should be ignorant of how the child component fades in/out (maybe with the exception of staggered animations).
  • The animations should integrate with React such that components are allowed to fade out (or otherwise complete their animations) before they are removed.
  • It should be possible to apply an animation to a component without modifying the component. It is ok that the component is styled such as to be compatible with the animation, but there should be no props, state, contexts or components related to the animation, nor should the animation dictate how the component is created.
  • It should be possible to perform an animation based on an action and the application state - in other words, when I have the full semantic context of what happened. For example, I might fade in a component when a thing has been created, but not when the page loads with the item in it. Alternatively, I might select the proper fade out animation, based on the user's settings.
  • It should be possible to either enqueue or combine an animation for a component.
  • It should be possible to enqueue an animation with a parent component. For example, if a component has two sub components and opening one would first trigger the other to close before opening itself.

The specifics of enqueuing animations can be handled by an existing animation library, but it should be possible to tie it in with the react and redux system.

One approach I have tried out is to create a decorator function like this (it is TypeScript, but I don't think that matters too much with regards to the problem):

export function slideDown<T>(Component: T) {
  return class FadesUp extends React.Component<any, any> {
        private options = { duration: 0.3 };

        public componentWillEnter (callback) {
            const el = findDOMNode(this).childNodes[0] as Element;
            if (!el.classList.contains("animated-element")) {
                el.classList.add("animated-element");
            }

            TweenLite.set(el, { y: -el.clientHeight });    
            TweenLite.to(el, this.options.duration, {y: 0, ease: Cubic.easeIn, onComplete: callback });
        }

        public componentWillLeave (callback) {
            const el = findDOMNode(this).childNodes[0] as Element;
            if (!el.classList.contains("animated-element")) {
                el.classList.add("animated-element");
            }

            TweenLite.to(el, this.options.duration, {y: -el.clientHeight, ease: Cubic.easeIn, onComplete: callback});   
        }

        public render () {
            const Comp = Component as any;
            return <div style={{ overflow: "hidden", padding: 5, paddingTop: 0}}><Comp ref="child" {...this.props} /></div>;
        }

    } as any;
}

...which can be applied like this...

@popIn
export class Term extends React.PureComponent<ITermStateProps & ITermDispatchProps, void> {
    public render(): JSX.Element {
        const { term, isSelected, onSelectTerm } = this.props;
        return <ListItem rightIcon={<PendingReviewIndicator termId={term.id} />} style={isSelected ? { backgroundColor: "#ddd" } : {}} onClick={onSelectTerm}>{term.canonicalName}</ListItem>;
    }
}

Unfortunately it requires the component to be defined as a class, but it does make it possible to declaratively add an animation to a component without modifying it. I like this approach but hate that I have to wrap the component in a transition group - nor does it address any of the other requirements.

I don't know enough about the internal and extension points of React and Redux to have a good idea how to approach this. I figured thunk actions would be a good place to manage the animation flows but I don't want to send the action components into the actions. Rather, I would like to be able to retrieve the source component for an action or something like that. Another angle could be a specialized reducer which passes in both the action and the source component, allowing you to match them somehow and schedule animations.

So I guess what I'm after is one or more of the following:

  • Ways to hook into React and/or Redux, preferably without destroying performance or violating the basic assumptions of the libraries.
  • Whether there are any existing libraries that solves some or all of these issues and which would be easy to integrate into the application.
  • Techniques or approaches to achieve all or most of these goals by either working with the normal animation tools or integrating well into the normal building blocks.

回答1:

I hope I understood everything right… Dealing with React + Redux means, that in best case your components are pure functional. So a component that should be animated should (IMHO) at least take one parameter: p, which represents the state of the animation. p should be in the interval [0,1] and zero stands for the start, 1 for the end and everything in between for the current progress.

const Accordion = ({p}) => {
  return (
    …list of items, each getting p
  );
}

So the question is, how to dispatch actions over time (what is an asynchronous thing), after the animation started, until the animation is over, after a certain event triggered that process.

Middleware comes in handy here, since it can »process« dispatched actions, transform them into another, or into multiple

//middleware/animatror.js

const animator = store => next => action => {
  if (action.type === 'animator/start') {

    //retrieve animation settings 
    const { duration, start, … } = action.payload;

    animationEngine.add({
      dispatch,
      action: {
       progress: () => { … },
       start: () => { … },
       end: () => { … }
      }
    })
  } else {
    return next(action);
  }
}

export default animator;

Whereby the animationEngine is an Instance of AnimatoreEngine, an Object that listens to the window.requestAnimationFrame event and dispatches appropriate Actions. The creation of the middleware can be used the instantiate the animationEngine.

const createAnimationMiddleware = () => {
  const animatoreEngine = new AnimatorEngine;

  return const animator = store => next => action => { … }

}

export default createAnimationMiddleware;

//store.js
const animatorMiddleware = createAnimationMiddleware();

…

const store = createStore(
  …,
 applyMiddleware(animatorMiddleware, …)
)

The basic Idea is, to »swallow« actions of type animator/start, or something else, and transform them into a bunch of »subactions«, which are configured in the action.payload.

Within the middleware, you can access dispatch and the action, so you can dispatch other actions from there, and those can be called with a progress parameter as well.

The code showed here is far from complete, but tried to figure out the idea. I have build »remote« middleware, which handles all my request like that. If the actions type is get:/some/path, it basically triggers a start action, which is defined in the payload and so on. In the action it looks like so:

const modulesOnSuccessAction => data {
  return {
    type: 'modules/listing-success',
    payload: { data }
  }
}

const modulesgetListing = id => dispatch({
  type: `get:/listing/${id}`,
  payload: {
    actions: {
      start: () => {},
      …
      success: data => modulesOnSuccessAction(data)
    }
  }
});

export { getListing }

So I hope I could transport the Idea, even if the code is not ready for Copy/Paste.