Skip to content

Transitions/animations brain dump #1431

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Rich-Harris opened this issue May 7, 2018 · 8 comments
Closed

Transitions/animations brain dump #1431

Rich-Harris opened this issue May 7, 2018 · 8 comments

Comments

@Rich-Harris
Copy link
Member

Fair warning: this will be a long rambly issue. I just want to get this stuff out of my head and into a place where the missing pieces will hopefully be a bit clearer.

Svelte's transition system is powerful, but only applies to a fairly narrow range of problems. (There are also some outstanding issues that need to be resolved one way or another — #544, #545, #547 and #1211, which is related to #547, spring readily to mind.)

We don't have a good way to achieve certain effects that are rightly gaining in popularity among web developers. Most of what I'm about to describe is almost certainly possible already, but I don't think it would be at all easy or declarative.

I urge anyone who is interested in this stuff to watch Living Animation by Edward Faulkner, which was recorded at EmberConf recently. I'm going to describe three particular scenarios based on examples from Edward's talk, and suggest the primitives that we need to add to Svelte in order to make them easy to do.

Scenario 1 — FLIPping a list

We have a list, which can shuffle. Items may enter or leave the list. We want to move elements smoothly to their new position using a FLIP-like mechanism. (See react-flip-move and Vue for prior art.)

But we don't want a fixed duration; we might want duration to be dependent on distance to travel, or on bounding box size (to simulate 'mass'). Maybe we don't want to travel in a straight line; maybe we want to change scale along the way — in other words we need fine-grained parameterisation and programmatic control, not just CSS transitions. (But we want them to be powered by CSS animations under the hood for the perf benefits etc.)

Stretch goal: we want to preserve momentum, or simulate springiness. Stretch goal 2: z-index should preserve the apparent z relationships between elements if a reshuffle happens during an animation.

What's missing

We have a concept of transitions, but we don't have a concept of animating something from one place on the page to another. We do, however, have a primitive that lets us reorder things on the page — the keyed each block. Vue appears to have reached the same conclusion — FLIP animations can only happen inside <transition-group>, and each element must have a :key.

Proposal: we add an animate directive. Whenever a keyed each block updates, the following sequence of events happens:

  1. The bounding box of every element inside the block with the animate directive is measured
  2. The list is updated
  3. We measure bounding boxes again
  4. Any items that have been removed from the list need to be transitioned out, if a transition was provided. For that to work visually, we need to set position: absolute on those elements and use transform to make it look as though they haven't moved, and only then run the outro
  5. We create an animation object for each element that moved, containing layout information (we can probably get away with just the change in x and y value, plus maybe some other stuff like actual distance moved). The animation function — flip, in the example below — is responsible for using that animation object to calculate CSS. It returns the same kind of object that transition functions do — i.e. if it returns a css function, that will be used to create a CSS keyframe animation that smoothly animates the element to its home position. delay, duration and easing would also be respected, just like with transitions.
  6. Any new elements will be introed, if an intro was specified.

As an aside, css: t => {...} is all well and good but you often find yourself doing 1 - t inside those functions. It would certainly make my life easier if that value was supplied, so I suggest we change the signature to css: (t, u) => {...} ('time', 'until').

{#each things as thing (thing.id)}
  <div animate:flip transition:fly>{thing.name}</div>
{/each}

<script>
  import * as eases from 'eases-jsnext';
  import { fly } from 'svelte-transitions';
  
  export default {
    animations: {
      flip(animation, params) {
        return {
          duration: Math.sqrt(animation.d) * 100,
          easing: eases.easeOut,
          css: (t, u) => {
            const dx = u * animation.dx;
            const dy = u * animation.dy;
  
            return `transform(${dx}px, ${dy}px);`
          }
        };
      }
    },
  
    transitions: { fly }
  };
</script>

A challenge: How do we ensure that content below the animated list doesn't jump around?

Scenario 2 — transferring items between lists

Now suppose we have two lists. Clicking on an item in either sends it to the opposite list. As before, we need to consider how items move within the list, but we now also need to handle 'sent' and 'received' items.

What's missing

I think we're actually almost there. We could create send and receive transitions using the existing API, and those could talk to each other — the sent nodes could be used as the basis for the received node's transition functions (using something like the technique from this sadly abandoned library: https://github.com/Rich-Harris/ramjet).

But there's no guarantee that we'd be able to register nodes as having been sent before they were 'claimed' by the receiver. I think the way around this is to allow transitions to return a function:

transitions: {
  foo(node, params) {
    doSomeImmediateSetup();

    return () => {
      doSomeStuffThatCantHappenImmediately();
      return {
        // standard transition object
        css: t => {...}
      };
    };
  }
}

The transition manager would run all the transition functions, and if any of them returned new functions, it would resolve a promise (easy way to wait for work to complete without waiting for a new turn of the event loop, which would result in visual glitchiness) then call those functions.

An example of how that might work is shown below. I wouldn't expect people to actually write that code; we would have helper functions that did the heavy lifting, which I'll show in scenario 3.

<div class='left'>
  {#each left_things as thing (thing.id)}
    <div
      animate:flip
      in:receive="{key: thing.id}"
      out:send="{key: thing.id}"
    >{thing.name}</div>
  {/each}
</div>

<div class='right'>
  {#each right_things as thing (thing.id)}
    <div
      animate:flip
      in:receive="{key: thing.id}"
      out:send="{key: thing.id}"
    >{thing.name}</div>
  {/each}
</div>

<script>
  import { flip } from 'svelte-animations';
  
  let requested = new Map();
  let provided = new Map();
  
  export default {
    animations: { flip },
  
    transitions: {
      send(node, params) {
        provided.set(params.key, {
          node,
          style: getComputedStyle(node),
          rect: node.getBoundingClientRect()
        });
  
        return () => {
          if (requested.has(params.key)) {
            requested.delete(params.key);
            // for this transition, we don't need the outroing
            // node, so we'll just delete it. in some cases we
            // might want to e.g. crossfade
            return { delay: 0 };
          }
  
          // if the node is disappearing altogether
          // (i.e. wasn't claimed by the other list)
          // then we need to supply an outro
          provided.delete(params.key);
          return {
            css: t => `opacity: ${t}`
          };
        };
      },
  
      receive(node, params) {
        requested.add(params.key);
  
        return () => {
          if (provided.has(params.key)) {
            provided.delete(params.key);
  
            const { rect } = provided.get(params.key);
  
            const thisRect = node.getBoundingClientRect();
            const dx = thisRect.left - rect.left;
            const dy = thisRect.top - left.top;
  
            const { transform } = getComputedStyle(node);
  
            return {
              t => `transform: ${transform} translate(${t * dx}px,${t * dy}px)`
            };
          }
  
          requested.delete(params.key);
          return {
            css: t => `opacity: ${t}`
          };
        };
      }
    }
  };
</script>

Scenario 3 — cross-route transitions

This is actually pretty similar to the previous example, except that we don't have any animations in this one — just transitions. This time, we'll use an imaginary helper function to handle all the book-keeping and matching.

We have a list of items on one page (could be keyed, could be unkeyed) — let's say it's the HTTP status dogs. Clicking on one takes you to a dedicated page. The selected image moves smoothly (and enlarges) to its new home; the others fly off to the left or something. Navigating back reverses the transition (even if it happens in-flight).

<!-- routes/index.html -->
{#each statusDogs as dog}
  <a href="status/{dog.status}">
    <img
      alt="{dog.status} status dog"
      src="images/{dog.status}.jpg"
      in:receive="{key: dog.status}"
      out:send="{key: dog.status}"
    >
  </a>
{/each}

<script>
  const { send, receive } = notSureWhatToCallThisFunction({
    send(match, params) {
      // return nothing to signal the node should be removed
      // (or just omit this option, I guess)
    },
  
    receive(match, params) {
      // this function will be called for one of the lucky
      // dogs if we're navigating back from e.g. /status/418
      return {
        // create the CSS that smoothly animates the dog
        // back to its spot in the list
        css: t => {...}
      };
    },
  
    intro(node, params) {
      // this will be called for all the other dogs
      // when we land on this page. fade them in or something
      return {...};
    },
  
    outro(node, params) {
      // this will be called for all the other dogs
      // when we *leave* this page
      return {...};
    }
  
    // in many cases intro and outro will be the same...
    // maybe this function should accept a 'transition' option
  });
  
  export default {
    transitions: {
      send, receive
    }
  };
</script>
  
<!-- routes/status/[code].html -->
<header>
  <h1>{params.code}</h1>
  <img
    alt="{dog.status} status dog"
    class="large"
    in:receive="{key: dog.status}"
    out:send="{key: dog.status}"
  >
</header>

<p>{getDescription(params.code)}</p>

<script>
  const { send, receive } = youKnowTheDrillByNow({...});
  
  export default {
    transitions: { send, receive }  
  };
</script>

If anyone made it this far, kudos. I would love to know if there are parts to this that I've overlooked, or any trickier use cases to test the ideas against, or if you have improvements to the above proposals.

To recap, the bits I think we should add:

  • animate directive that works inside keyed each blocks
  • css: (t, u) => {...}
  • allow transition functions to return a function instead of a transition object

One thing I haven't considered here is the Web Animations API; I've been assuming that we'd use CSS animations which I think are probably more suitable. But I'm open to persuasion if you think I've got that wrong.

@arxpoetica
Copy link
Member

arxpoetica commented May 7, 2018

I just finished watching Edward Faulkner's talk. TBH, I'm not sure what was innovative about it other than maybe he's just simplifying a problem? To that end, this is an area I do want to see reduced to utter simplicity. Being one of those "story engineers" who has thought a lot about platforms and animation, I always want animation to be simpler than it is. And it's almost never easy. Even with Svelte's current powerful transition tools. I'd love it we could find that sweet spot.

I think you've got a good starting list. (I admit I was getting a little thrown when you said "lists" since I was thinking ul/li, but then realized you meant arrays.) Let me know if you want a sounding board on it.

One more thought. Sapper has the potential to "pack a punch" out of the box on this stuff. I wonder if the default setup ought to include a sample page that employs the power of this animation? Just a thought.

@TehShrike
Copy link
Member

Cross-route transitions might be made more difficult by nested routes - e.g., on the HTTP Status Dogs page, when you click on a specific dog, everything in the header above the hr wouldn't animate in/out, it would just stay where it is.

I've never been super-confident about how I wanted to pull those kinds of transitions off, so I've never tried too hard :-x

@Rich-Harris
Copy link
Member Author

@TehShrike yes, you'd need to define transitions on the other elements if you didn't want them popping in and out. Though you do raise an issue that hadn't occurred to me, and which is a bit of a challenge: currently, transitions only run if the block they're an immediate child of is removed:

{#if foo}
  <div>
    {#if bar}
      <p transition:fade>
        this will fade in and out when `bar` is toggled, but will
        abruptly disappear when `foo` is toggled
      </p>
    {/if}
  </div>
{/if}

So if our list of dogs were inside an element with a transition, the dog wouldn't get 'captured' when it was clicked on, because the send transition would never run.

That's a semi-deliberate design decision: it certainly made the implementation easier, but it was justified by the fact that there are lots of situations where you don't want nested transitions:

<!-- it would be weird if each <li> slid out while the <ul> itself was fading -->
{#if showList}
  <ul transition:fade>
    {#each list as item (item.id)}
      <li transition:slide>{item.name}</li>
    {/each}
  </ul>
{/each}

But it's going to make more sophisticated stuff a lot harder. So I think we perhaps need to rethink it. Perhaps outros in nested blocks should play by default, unless they are explicitly blocked with another directive:

<!-- it would be weird if each <li> slid out while the <ul> itself was fading -->
{#if showList}
  <ul transition:fade childtransition:false>
    {#each list as item (item.id)}
      <li transition:slide>{item.name}</li>
    {/each}
  </ul>
{/each}

Here, childtransition:false means 'don't play intro/outro transitions on children of this node when this node is itself transitioning'. There could be childin:false and childout:false directives for more fine-grained control. (Bikeshedding welcome.)

Along with this, we would presumably want to default to outroing components. That would mean adding an outro method to all components (i.e. not making it opt-in, per #1211):

component.outro().then(() => {...});

This would, of course, have a negative (albeit small, I think) impact on generated code size. But it would enable some things that are currently much too difficult, and so I think we should probably do it.

One problem I haven't yet got my head round: how to handle components with slotted content that contains outros.

@jacwright
Copy link
Contributor

Would the web animations API make it any easier or provide better results to reverse an in-flight animation? Such as an element inside an {#if} that toggles in the middle of animating. If not, I see no benefit to using it under the hood. Only benefit is people use it directly.

@Rich-Harris
Copy link
Member Author

Svelte already does that 😀

@Rich-Harris
Copy link
Member Author

Going to close this as the bulk of the work has been done. Couple of demos:

@jacwright
Copy link
Contributor

jacwright commented May 17, 2018

Update: moved to #1480 for further discussion.

Note: The following opinions are coming from a view that animations and transitions are most often used in web apps to call attention to changes in a page that is already loaded, and that the area of the page handling the change should be the one defining the transition. For example, the part that deals with changing the entire page in a SPA as you navigate might fade the entire page in (or might choose not to employ any transition), but elements on the page wouldn't fade themselves in unless their data was not loaded at the initial transition, in which case they might fade in after their portion of the data was loaded. Coordinating transitions throughout a page to run together should still be possible for advanced effects but should not be the default.


Allowing child transitions to run will give fine-grained control and allow more robust use-cases. However, the vast majority of the time this will not be used. I propose the default be childtransition:false and childtransition:true be used instead for those cases when you want the lower animations to run.

I believe transition boundaries should be at the block level with EachBlock, AwaitBlock and IfBlock (including else of course). I don't think it should be at Component because a component with a transition at the top level (not inside a block) would have the expectation that the transition is run when the component is placed within an if/each using the same rules that an HTML element would within that if/each (e.g. skipping the intro/outro when first being added/removed).

The default rule of thumb should be, transition is tied to the if/each nearest it. It should not run (unless explicitly told to do so with an override) when that if/each is first inserted into the DOM or when it is removed, only when the value for the if/each changes and elements are added/removed.

If you want transitions to run across these boundaries then you can use childtransition:true. This would be necessary even if e.g. the outer if didn't have a transition. E.g.

{#if foo}
  <div childtransition:true>
    {#if bar}
      <p transition:fade>
        this will fade in and out when `bar` is toggled, but will
        abruptly disappear when `foo` is toggled
      </p>
    {/if}
  </div>
{/if}

This allows the most expected behavior to be default and prevents confusion that would otherwise prevail when changes in an app (route changes and other large and small changes) cause animations to run unexpectedly.

I believe this is the correct behavior for the majority of web apps and that the directives for childtransition:true and skipintro:false and skipoutro:false will be rarely used and only in special circumstances.

@andriytyurnikov
Copy link

andriytyurnikov commented Aug 15, 2019

Well, here are 2 cents - both vue-router, and ember's liquid fire, recognized potential value of being able to look into transition source and destination paths to deliver animations dependent on navigation path, not on route hierarchy.
As nesting is top-down tree, but navigation comes in all kinds of shapes and forms.

I am thrilled to see things like sveltejs/sapper#778 possible. Just want to share 2 things:

  1. author of liquid fire had to introduce common high-order container ( and actually included corresponding helper/component)
  2. having access to transition source and destination paths promises context-dependent transitions, not just single in/out

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants