Skip to content

Instantly share code, notes, and snippets.

@chenglou
Last active March 13, 2024 12:14
Show Gist options
  • Star 105 You must be signed in to star a gist
  • Fork 8 You must be signed in to fork a gist
  • Save chenglou/40b75d820123a9ed53d8 to your computer and use it in GitHub Desktop.
Save chenglou/40b75d820123a9ed53d8 to your computer and use it in GitHub Desktop.
Thoughts on Animation

Interesting part (unmounting & API) is at the end if you're not interested in the rest =).

Stress Tests

This animation proposal is just an attempt. In case it doesn't work out, I've gathered a few examples that can test the power of a future animation system.

  1. Parent is an infinitely spinning ball, and has a child ball that is also spinning. Clicking on the parent causes child to reverse spinning direction. This tests the ability of the animation system to compose animation, not in the sense of applying multiple interpolations to one or more variables passed onto the child (this should be trivial), but in the sense that the parent's constantly updating at the same time as the child, and has to ensure that it passes the animation commands correctly to it. This also tests that we can still intercept these animations (the clicking) and immediately change their configuration instead of queueing them.

  2. Typing letters and let them fly in concurrently. This tests concurrency, coordination of an array of children, whose positions depend on each other, and mounting and unmounting. Pretty much the stress test.

  3. Three boxes, from different hierarchies/owners, colliding and falling. The animating API doesn't need to (and shouldn't) implement its own physics engine; it should be able to reuse existing ones. Additionally, these animations might need a global knowledge of other components' animation configurations.

  4. A millisecond counter that uses easeout to increase its value at every second. This tests the system's ability to interpolate arbitrary values rather than, say, only CSS props.

  5. The animation should be (naturally) rewindable. I'll explain this further in the API section.

Goal

Find an animation API that leverages React's declarative paradigm rather than the traditional imperative one (e.g. jQuery). Note that jQuery's animations themselves aren't that imperative; it's more because of the traditional way these methods fit with the library.

Why It Is Hard

A major problem is that animation is tied to layout. Without a powerful layout system, everything seems much more tedious to write. I won't be solving layout problems here.

A bigger problem is that React's current model of render as snapshots doesn't play well with time. Realistically, JS' mutative nature also doesn't play well with time and comparison between past & present & future. Lots of logics in the animation system will depend on knowing about the past and future.

Why It Is Relatively Easy for Imperative Frameworks

In imperative frameworks, time isn't even a concern (which is not a good thing, but it does make hacking around it easier). The state of the app can end up somewhere else after a tween and the frameworks don't seek to control that. Time is simulated manually where needed, same for OOP in general: http://www.infoq.com/presentations/Are-We-There-Yet-Rich-Hickey

Current Solutions

CSS

CSS doesn't let us specify custom transitions beyond ease/linear/etc., and can only awkwardly specify animation. Since it doesn't tie into render's logic much, it feels like templating, for animation. Other shortcomings include no finer grained control over starting/stopping animation. When a render kicks in at an arbitrary moment, you lose information on the animation in the previous render, unless you do the bookkeeping yourself. This can get complicated. Also, no good hooks.

TransitionGroup

TransitionGroup's does 80% of the job with 20% of the effort. We'll have to rely on it for a while. See last section on unmounting.

What We're Not Going to Do

One prototype used something conceptually similar to CSS animations: the render is not touched. Under the hood, we track the styles applied and interpolate them. This is already much more powerful than CSS animations because we can specify custom animations.

The problem with this is that unmounting potentially becomes hacky. furthermore, it just feels more right to automate render to achieve animation (also see stress test 4). See more explanation in the API section.

Where The Controls Belong

At the beginning, it wasn't even clear whether the top-down approach worked well for animation. Layout certainly didn't. However, so far, React's top-down approach seems to translate well. Whatever API it is, it seems that it's better for the owner to control the animation of the children. This way, children stay totally oblivious of being animated. For practical purposes, we can wrap the children in a PredefinedAnimationConvenienceWrapper.

Describe UI Through Time

Every time I seem to end up putting animation configurations in lifecycle events. This is no better than jQuery, and it feels like no amount of sugar and helpers can transform these APIs into a truly declarative one. Based on the assumptions above, the better way to do it would be to find way to tell React what the UI looks like not at one single point, but through a period of time. But every time I try to describe the render through this "period of time", I end up automating the render for a while, a-la pete's tweenState. Note that this might not be a bad thing. I've implemented this here.

This solves some problems and is even capable enough to emulate some physics. Imo, the problem with these kind of jQuery-ish APIs is that you have to go out of your way to stop the animation, queue them, etc., no matter how short and sugary they might look like. Manually coordinating feels wrong when React potentially offers something better. Other frameworks probably (no research here) just set some flags to prevent the same animation (judged based on, say, equal destination and interpolation method).

A sensible default would be additive animation, which my repo above implements.

Timeline

(Relevant: new web animation standard proposal). The standard unfortunately doesn't cover animation on unmounting (and rightfully so, since current React's the only library experiencing this), and doesn't provide hooks for making things like physics easier. It's mostly a more sane API for the current possible animations, so this proposal won't go over it.

In Flash, the components have their respective timeline. A ball that loops and spins over 30 frames can be nested inside an owner that spins over 60 frames. This is Flash's main way of composing.

A component can have multiple timelines: https://wiki.brown.edu/confluence/download/attachments/4415/Flash_timeline.gif?version=1&modificationDate=1152908704000. (Arrows are interpolations. Dots are keyframes.)

Owner owns child, Layer2 belongs to owner. Child has its own internal timeline. Owner also assigns a timeline to it. Both owner and child are oblivious of each other's timeline. Aka, there's no way for a parent to stop its child's from playing its animations. It can only control the timeline it assigned to the child (unless the child expose an API through props).

You can have code on arbitrary frames and they'll get executed when it's reached. This might be appealing or repulsive:

var Counter = React.createClass({
  getInitialState: function() {
    return {stepper: 0};
  },

  componentDidMount: function() {
    this.play();
  },

  componentWillUpdate: function() {
    switch (this.currentFrame) {
      case 1:
        this.setNextState({stepper: this.state.stepper + 1000});
        break;
      case 1000:
        // Best API or worst API?
        this.gotoAndPlay(1);
        break;
    }
  },

  render: function() {
    var stepper = this.state.stepper;
    return (
      <div>
        {
          // Begin, end, currentTime, duration
          easeOut(stepper, stepper + 1000, this.currentFrame, 1000)
        }
      </div>
    );
  }
});

The catch with this is that it changes the logic we put in some lifecycle events such as willReceiveProps. The task of "playing animation when the parent rerenders" doesn't make sense anymore (aka, you can't start an animation configuration in willReceiveProps), since the parent could be rerendering at every frame. People have pointed out that we can still setAnimationConfig and ignore its subsequent, redundant calls, but imo this doesn't feel right.

Push Everything Down to DOMComponent

The second way is to stay in the current paradigm and only make ReactDOMComponents have a timeline every frame. This isn't just an implementation/optimization detail compared to the previous point. All the animation configurations are squashed to the leaves. The interesting part with this paradigm is that instead of relying on repeatedly rendering through raf, we can plug an existing animation engine, e.g. CSS or something else. More on this and the API in the API section, first proposal.

setStateArray

setStateArray(bla.map(...)) Create an array that describes the animation, where each cell corresponds to one frame. We can view our setState as a special case of setStateArray(repeat(stateObj)). This is kind of like FRP in that it uses common functional idioms to express your animation's progression. But in this case, we can simply use normal arrays (expensive), or lazy streams a-la Haskell/Clojure. Maybe even core.async.

Animating Multiple Things

Storing the animation configurations in the parent, in a state, quickly becomes tedious. Lots of previous values that can be computed on-the-fly will now be stored inside the state (e.g. children position. Layout also becomes a headache here as we move again from relative and start manually placing everything).

This can potentially get messy (but maybe I'm wrong). This might either be a conceptual problem or something that a good API can solve.

Physics

Physics is needed and can be used 90% of the time. However, it'll never be a silver bullet. At first glance, physics seems to be appealing because, among other reasons, it seems declarative and abstracts time away. We don't ever provide an interpolation: we only specify friction, weight, etc. But the reality is that UI breaks the assumptions of a real-world physics system a lot of times:

  • Button turns from green to red.

  • Lightbox enlarges and covers up the screen.

  • Box fades away while shrinking.

  • Assuming the animation system only allowed physics, it's even hard to specify a menu dropdown animation. Sure, you can imagine it as a heavy, horizontal rod placed at the end of the menu that drags it down until the menu stretches too much and bounce back a little; but it actually becomes, imo, difficult to reason about when you're attaching/removing strings everywhere and changing an object's weight and friction, then change it back, just so that it could move across the screen a certain way. This is viable, but I can't imagine how we can incorporate nothing but a physics-only system, in a paradigm that renders snapshots of the UI at a certain point in time, on a UI paradigm which magically move, stack and merge visual entities.

That being said, physics is the way to go. But if we go full physics, we'll have to provide an additional API for the above cases of animation.

Physics need to know more than the little information about begin/duration/whatever that we provide. Lots of times, it needs to know the animating state of parent, siblings, etc. If we want this to work reasonably in React's top-down model without passing callbacks everywhere, we'll need the animation manager to be at top level and all-knowing.

Animation Across Hierarchies

See "Describing UI Through Time" above. If we take the first route then we might have to rethink about how some lifecycle methods work, and expose new ones.

Unmounting

Huge headache, as this is the only case where data potentially goes out of sync with the view.

Side note: I've found that one heuristic that leads us onto the right path is if the animation's reversible. Just like Om can easily implement undo through persistent data structures, unmounting animation (and animation in general) should theoretically be something that we can rewind. This further explains the section "What We're Not Going to Do".

There are two major ways to solve this. Say from the perspective of the parent that passes a [1, 2] (soon to be [1]) state to child and the child renders it. Either we keep the data consistent and turn parent setState into a promise that resolves when the child animation is done, or we let parent immediately update its [1, 2], and do the TransitionGroup's trick of keeping a copy of the old data and animate it, hoping the data won't stay out of sync for too long.

setState Promise

This approach never lets data get out of sync. The data isn't changed until the unmounting animation's over.

The problem is animation concurrency. Every setState will forcefully be queued. This is not desirable, so scratching setState promise for the purpose of animation.

Clone and Animate Somewhere Else

We can literally clone the image (markup string, canvas pixels, etc.), move it somewhere else and position it correctly, and animate that after the original child's been removed. This does hacky stuff under the hood so I don't like it. More than cloning the node, we'd have to simulate everything that depended on it, notably its siblings' layout positioning. Potentially violates stress test 5 and doesn't look like something we can build on top of.

TransitionGroup's Way

The problem with TransitionGroup is that it reimplemented a small, non-customizable reconciler in itself. This is the source of many bugs, and many unfixable ones if we don't expose the reconciler's diffing step and let user control it. A simple way to break it is to pass [1, 2, 3] then setState to [2, 4]. Currently the rendered result will be [1, 2, 4, 3]. setState again during this period and... you get the point. We have no saying over how the diffing's done.

This goes deeper as TransitionGroup assumes that things don't get out of sync too long. During this period of transitioning, it reports a different child count than the parent.

So, the current execution timeline:

parent setState -> render -> child render -> TransitionGroup mini reconciler

There has to be a way to include unmounting in a general concept rather than special-casing it. Let's take [1, 2, 3] -> [1, 3] as example. My new starting point was to consider unmounting as it is: after props.items has shrunken, no hack should bring the lost item back. This means that any transitioning out animation that we do will assume that we're working with the old props.items (where the item is still present). This also means the reconciler needs to diff this correctly, since if multiple items are coming in and going out, the child that renders the parent's props will have at one point many more than 3 items.

TransitionGroup's idea itself is not bad; we just have to implement this at React level and make it leverage the actual (customizable) reconciler. The new flow:

parent setState -> child willReceiveProps (+ render preview) -> parent render -> child automated forked render -> child final render

The magical part here is to preview the next render result and apply a diff on top of the current one. We then target these diffed components and command animation configurations on them. Doing diffing/patching on render result rather than text sounds crazy.

Note that the parent's state.items is already updated at step 3. A few assumptions are made here:

  • Things stay as immutable as possible. Screwing around deeply nested objects in props/state will not end well.

  • key plays a very critical role here, diffing incorrectly in the past was fine: the render can unmount/re-mount the same thing and the result still looks correct. But animation will expose the flaw very visually if you don't diff the items correctly, e.g. the wrong item might be moving around.

  • To enable preview, render should become a function of props and state and should not rely on this.props and this.state: facebook/react#1387.

Note: The magical (and expensive) part is if we can stack all the diffs through time: [1, 2] -> [1] -> [1, 2] should bring the almost unmounted 2 back visually, even though the instance is destroyed and recreated.

In this case, key wouldn't just be the identity of the current component, but the identity of this component at any moment; It's effectively a hash that allows you to identify the component that might unmount halfway and come back before disappearing. if it's not key, we need some way to tell the reconciler that the component coming back is the same that was being unmounted (diffing through time!).

This concepts seems nice from a first glance. Providing that the diffing works well, this can be infinitely scalable, with as many concurrent mount/unmount. The beef I have against this approach is that it doesn't follow well the heuristic I've mentioned at the beginning (reversiblility). This seems less coherent from a CS standpoint.

API

All the API proposals here revolve around automating render through requestAnimationFrame. There are a few reasons why I didn't go with the CSS route of animating things under the hood:

  • It only works for DOM. We want better abstractions. Ideally, we should be able to take the animation system/hooks and easily apply them to canvas, svg, etc. This might still be possible in the CSS animation paradigm if we do something like React.Animation.inject(canvasImplicitAnimationStrategy). But I don't like it...

  • It feels very situation-specific and not CS-y. See stress test #5. Animation should be snapshottable, akin to Om's natural undo. THe fact that undo is made effortless through persistent data structures + React's optimization should be a goal of animation too; I should be able to undo the animation by reversing it. This feels theoretically solid.

General

setStateArray

{
  handleClick: function() {
    var newStates = range(0, 1000).map(function(tick) {
      return {
        posX: interpolate(50, 150, tick, 'easeOut')
      };
    });
    this.setStateArray(newStates);
  }
  ...
}

Ideally, when we ditch setState in favor of returning it, this'd give a pretty nice API. This is what I'm gonna explore next.

"Squashing everything down to DOMComponent"

render: function() {
  var style = {
    height: 20,
    width: 50,
    transition: customFunc(a, b, c)
  };
  return <div style={style} />;
}

Looks neat. Only works with style values. A bit informal.

tweenState & variations

https://github.com/chenglou/react-tween-state

Event handlers

handleDivEachFrame: function() {
  if (someAnimationCondition) {
    return {
      a: animationConfigForA,
      b: animationConfigForB
    };
  }
  // No animation.
  return null;
},

render: function() {
  return (
    <MyComp a={5} b={6} c={7} onEachFrame={this.handleDivEachFrame}>
      <div>bla</div>
    </MyComp>
  );
}

Not sure about this one.

Obligatory Physics Engine

var App = React.createClass({
  getInitialState: function() {
    return {
      params1: {weight: 10, friction: 5, ...}
      params2: {...}
    };
  },

  handleClick: function() {
    params1 = {weight: 20, friction: 10};
    this.setState({params1: params1});
  },

  render: function() {
    return <div><Child physicsParams={this.state.params1} /></div>;
  }
});

Mostly a no-brainer. Should plug well into, say, Box2d.

Unmounting

var App = React.createClass({
  componentWillUpdate: function(nextProps, nextState) {
    var render1 = this.render(this.props, this.state);
    var render2 = this.render(nextProps, nextState);
    var diff = React.Reconciler.diff(render1, render2);
    var configDiff = diff.map((item) => {
      if (item.transitioning) {
        return;
      }
      if (item.updateType === UPDATE) {
        return someAnimationConfigObject;
      } else if (item.updateType === REMOVE) {
        return someOtherNotSoDifferentConfigObject;
      }
    });

    return configDiff;
    // this.automateForkedRenderWithConfig(configDiff);
  },

  render: function() {
    return (
      <div>
        {this.props.items.map((item) => <div>{item}</div>)}
      </div>
    );
  }
});

Not sure how this fits in the general animation API.

It'd be nice to make use of lifecycle return values, although I'm not sure whether passing the animation diff config is its best use (in case it's not, we can use the commented out API). This forks the render into whatever config we've passed (nice thing about returning it is it might be easier to have control over when to actually start/stop the animation, etc.), automate it until it reaches the normal, post-animation render.

We should avoid declaring a duration in the API; rather, for each animation configuration, we call a callback each frame and check whether the return value is true. This generalizes well to duration-less animations such as physics. This way, we can leverage whatever third-party animation library rather than reinventing the wheel.

Crazier Stuff

Providing that the above unmounting idea is realistic, we'll have this concept of combined renders, where the same component, seen only once in a render, can now appear twice. Ignoring the fact that this is a massive diffing fail (can be avoided), we can intentionally cause this and use it for crossfading and swiping between images. For the latter example, at one point during the transitioning out of an image and the transitioning in of the next one, we see two images on the screen. Without animation, our code would look like:

var App = React.createClass({
  getInitialState: function() {...},

  handleClick: function() {
    this.setState({...});
  },

  render: function(props, state) {
    return (
      <div onClick={this.handleClick}>
        <img key={state.currImageIndex} src={imageURLs[state.currImageIndex]} />
      </div>
    );
  }
});

With animation, our render logic doesn't change!

var App = React.createClass({
  getInitialState: function() {...},

  handleClick: function() {
    var render1 = this.render({currImageIndex: this.state.currImageIndex});
    var render2 = this.render({currImageIndex: this.state.currImageIndex + 1});
    var mergedRender = React.Reconciler.merge(render1, render2);
    this.transitionWithThisRender(mergedRender, moreAnimationConfig, cb);
  },

  render: function(props, state) {
    return (
      <div onClick={this.handleClick}>
        <img key={state.currImageIndex} src={imageURLs[state.currImageIndex]} />
      </div>
    );
  }
});

Would be great if animation doesn't get in the way of render, even in this situation. In general, this screws up stuff like this.props.children so the whole thing's just food for thought for now.

Perf concerns

Assuming we do go down the road of animating through using rAF on the render, we should leverage React's upcoming rAF batching to avoid layout thrashing. Not developing this section until we nail down an API.

Other Thoughts

Can FRP's map/reduce/filter composition play a role in animation? http://youtu.be/XRYN2xt11Ek?t=13m5s

Additive animation: the default that CSS animation should have. https://developer.apple.com/videos/wwdc/2014/, "Building Interruptible and Responsive Interactions". Somewhere in the middle. Must watch. It's a very elegant solution to transitioning between animations, for non-physics animations.

core.async might be interesting. Maybe this is a better way to coordinate stuff than pure promises for, say, setState.

key (identity in general) needs to be more powerful: https://groups.google.com/forum/#!topic/reactjs/ySPp8ksaDAc not just for perf reasons, but also to further help the reconciler to know where we moved items.

Layout is a headache because it adds a bit of manual management each time. Any current layout system isn't flexible/powerful enough to cooperate with animated physics like chat heads.

setState promise might still be useful for batched rendering, partial reconcile, layout, etc.

Om leverages Clojure's persistent data structures so well that it's basically a better React without the handicap of JS. We might want to look more at it in the future for layout and animation (instruments, tx-listen, etc.

@samzhao
Copy link

samzhao commented Apr 29, 2015

Incredibly well written 👍.

@mattgperry
Copy link

Very interesting. I've been looking into providing a React interface for Popmotion so this has provided a ton of food for though, thanks.

One thing I'd say though, physics can be used for non-physical properties quite easily. Here's an example of spring physics being used to change a background color: http://codepen.io/popmotion/pen/EVbrzj?editors=001

Scale/width/height/opacity can all be handled the same way. Sometimes these effects are more satisfying than your average 'back-out' easing curve.

It still isn't a magic bullet, because it is often a nicer and simpler mental model for a developer to use tweens. You get what you ask for. But the use-case for physics is broader than it initially seems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment