Skip to content
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

Transitions #525

Merged
merged 34 commits into from
May 3, 2017
Merged

Transitions #525

merged 34 commits into from
May 3, 2017

Conversation

Rich-Harris
Copy link
Member

This will probably be WIP for a while, as there will no doubt be lots of 'fun' challenges to solve along the way. So far, just adding support for in, out and transition directives (where transition just means 'both in and out').

@Rich-Harris
Copy link
Member Author

Rich-Harris commented Apr 26, 2017

Made some modest progress:

fade

Right now, just intro transitions, and only JS (timer) based ones at that. Realised that in a lot of cases you want the transition function to determine the duration of the transition (e.g. the duration of a transition might be determined by some other value e.g. position or size), so I currently prefer this API for timer-based transitions:

transitions: {
  myTransition ( node, params ) {
    // setup code...

    return {
      delay: params.delay || 0,
      duration: params.duration || 400,
      easing: eases.cubicOut,
      tick: t => {
        // code fired on each animation frame
      }
    };
  }
}

CSS transitions

For CSS-based transitions (which are generally preferable, since they don't run on the main thread, but traditionally are a lot harder to deal with), the approach I'm planning to take is to create a bespoke CSS animation for each node. That way, any JS easing function can be used — we simply create a bunch of keyframes programmatically (using the JS easing function) and progress through them with animation-timing-function: linear. The way I see it there are two ways this could be done:

Specifying target styles

transitions: {
  fly ( node, { x = 0, y = 0, delay = 0 } ) {
    return {
      delay: options.delay,
      duration: Math.sqrt( x * x + y * y ),
      easing: eases.cubicOut,
      styles: {
        opacity: 0,
        transform: `translate(${x},${y})`
      }
    };
  }
}

In this case, we specify a map of styles to transition from (for intro transitions) or to (for outro transitions), and Svelte generates keyframes automatically:

// pseudo-code
var target = getComputedStyles( node );
var keys = Object.keys( obj.styles );

for ( let i = 0; i < 1; i += step ) {
  var t = ease( i );
  var styles = keys
    .map( key => `${key}: ${interpolate(obj.styles[ key ], target[ key ], t)}` )
    .join( '; ' );

  keyframes.push( `${i*100}%: { ${styles} }` );
}

var css = `@keyframes svelte_${uid} { ${keyframes.join( '\n' ) }`;
addCss( css );
node.styles.animation = `svelte_${uid} ${duration} linear`;

Gets a little trickier with things like transforms because a) they're more work to interpolate, and b) you want to preserve any existing transforms.

Specifying styles per-frame

Alternatively, the transition function could be responsible for generating the keyframes:

transitions: {
  fly ( node, { x = 0, y = 0, delay = 0 } ) {
    const target = getComputedStyles( node );
    const opacity = +target.opacity;

    return {
      delay: options.delay,
      duration: Math.sqrt( x * x + y * y ),
      easing: eases.cubicOut,
      styles: t => {
        const r = 1 - t;
        return `opacity: ${t * opacity}; transform: ${transform} translate(${r * x},${r * y});`
      }
    };
  }
}

Clearly that's a bit more work for the transition author (though the idea is that most of them would be installed from npm or even built-in, rather than handwritten each time), but it provides a lot of flexibility. For example, you could generate sophisticated data-driven keyframe animations such as a 12-principles-of-animation-style squash and stretch effect that took into account the size (read: 'weight') of the object that was bouncing in:

import keyframes from 'svelte-transitions/keyframes.js'; // TODO...

export default {
  transitions: {
    fall ( node, { stretch = 0.5 } ) {
      const { bottom } = node.getBoundingClientRect();

      return {
        duration: Math.sqrt( params.size ) * k,
        // elongate and accelerate while falling, squish, bounce, then fall again
        style: keyframes([
          0, `transform: translate(0,${-bottom}px) scale(1,1)`,
          eases.cubicIn,
          0.7, `transform: translate(0,0) scale(${1 - 0.2 * stretch},${1 + 0.2 * stretch})`,
          eases.cubicOut,
          0.75, `transform: translate(0,0) scale(${1 + 0.5 * stretch},${1 - 0.5 * stretch})`,
          eases.cubicOut
          0.9, `transform: translate(0,${-bottom*0.2}px) scale(${1 - 0.1 * stretch},${1 + 0.1 * stretch})`,
          eases.cubicIn,
          1, `transform: translate(0,0) scale(1,1);
        ])
      }
    }
  }
};

This is much more flexible and portable than approaches based on actually describing animations in CSS, but retains all of the advantages.

I started writing this thinking that maybe both forms would be supported, but the more I think about it the more I think it's probably better to just have the second form. That way, Svelte doesn't need to include code for interpolating e.g. colours just in case a transition uses them.

Except....

if we supported the built-in in:style transition suggested by @evs-chris — on encountering in:style='{color: 'blue'}, Svelte could determine at compile-time that it needs to be able to interpolate colours.

Speaking of only including necessary code:

Determining whether transitions are JS-based or CSS-based

As I've described things so far, Svelte would need to be able to accommodate both JS and CSS-based transitions, choosing which mechanism to use based on whether the transition function returns an object with a tick function or a styles function. That's not very Svelte-like. But I'm not sure if there's a good way to indicate at compile-time which kinds of transitions are used — the best I've come up with so far is this:

export default {
  cssTransitions: {
    fade: ...
  },

  jsTransitions: { // or rafTransitions? timerTransitions? frameTransitions?
    grow: ...
  }
};

Not all that user-friendly, though I suppose you could make the case that it forces component authors to know what kinds of transitions they're using and to understand the difference.

Perhaps we shouldn't worry about it too much, unless it turns out to be a lot of wasted code. After all no-one should really expect transitions to be free.

Other stuff I'm thinking about

  • Detaching (aka unmounting) nodes, and how to group detach operations together
  • Composing intro and outro transitions (e.g. if the fall transition above were paired with a fade outro transition, you might want the node to continue falling while fading out, if the outro started before the intro finished)
  • How to simulate spring physics with easing functions, and whether there's an idiomatic way to preserve e.g. velocity at the point of an aborted intro so that it can be taken account of by the corresponding outro
  • How to skip intros on first render, if desired

All thoughts welcome.

@Rich-Harris
Copy link
Member Author

Bit more progress — got an element to transition in and out. Baby steps:

fade-inout

@Ryuno-Ki
Copy link

https://developers.google.com/web/updates/2017/03/performant-expand-and-collapse

Dealt with a similar topic last month.
Having a reversed animation calculated could be handy.

@Rich-Harris
Copy link
Member Author

@Ryuno-Ki interesting, thanks, I hadn't come across that technique. It doesn't strictly apply here as this is purely about how to manage elements that are entering or exiting the DOM, whereas the clipping technique shown assumes that the DOM elements are always there. That's the sort of thing that would best be encapsulated as a component in Svelte.

Right, time for another small progress report. CSS animations now work as well as JS ones — albeit only in a subset of if-blocks for now (still need to do each-blocks and compound if-blocks).

Bidirectional transitions

After wrestling with this problem for a while I concluded that we need to have a concept of bidirectional transitions, which look like this:

fly-fly

(Excuse the framerate, it's a lousy GIF.) What's happening here is that (because the element has transition:fly rather than in:fly and out:fly) a single transition object is created, and we keep running it in different directions until it has fully outro'd. (At that point, the block is destroyed, and would need to be recreated.)

Running the transition means dynamically generating keyframes and throwing them into the DOM. We keep track of the current progress using a timer so that we can generate keyframes for a subset of the 'return journey' — I was worried this might lead to glitching but it seems to work rather well.

The code for the fly transition looks like this:

fly ( node, params ) {
  node.style.willChange = 'transform';
  const { x = 0, y = 0 } = params;

  return {
    delay: 0,
    duration: params.duration || 400,
    easing: eases.quadOut,
    styles: t => {
      const s = 1 - t;
      return `transform: translate(${s * x}px,${s * y}px);`;
    }
  }
}

Needless to say you could add other styles if you wanted, such as opacity.

Overlapping transitions

I'm going to state an opinion: removing a block while it's introing should not cause the intro transition to abort. (I wanted to avoid opinions in favour of flexibility as far as possible, but at some point you have to have some, I think.)

So if your element doesn't have a bidirectional transition, the outro will happen at the same time as the intro:

fly-blur

Notice that the text continues to fly down while it blurs out — it doesn't snap to its end position or stay where it currently is.

If you bring a block back while it's outroing, it does abort the outro. I couldn't think of a better way to handle that scenario, though if anyone has a better idea then shout.

The code for that blur transition looks like this:

blur ( node, params ) {
  const radius = params.radius || 4;

  return {
    delay: params.delay || 0,
    duration: params.duration,
    styles: t => {
      return `opacity: ${t}; filter: blur(${radius * ( 1 - t )}px);`;
    }
  };
}

Easing equations

Right now, for the easing on the fly transition we're importing eases-jsnext. That's fine, but it means that if someone wanted to specify one of those easing functions in their parameters, it would look like this:

<div in:someTransition='{easing:"elasticOut"}'>...</div>

<script>
  import * as eases from 'eases-jsnext';

  export default {
    transitions: {
      someTransition ( node, params ) {
        return {
          easing: eases[ params.easing ],
          // ...
        };
      }
    }
  };
</script>

The dynamic namespace lookup defeats tree-shaking. So I'm wondering if maybe Svelte could expose those easing functions, and replace easing:"validEasingFunctionName" with easing:eases.validEasingFunctionName, so that the lookup is unnecessary, and the transition author doesn't need to depend on an easing function library (unless they want to do something totally bespoke).

Is that too magical?

@Rich-Harris Rich-Harris changed the title [WIP] Transitions Transitions May 1, 2017
@Rich-Harris
Copy link
Member Author

Ok, I've taken the WIP tag off the title, if anyone is masochistic enough to review this PR...

Transitions should still be considered experimental for the moment — there are some known bugs around keyed each blocks, and not all features have been implemented, but I think it makes sense to get this merged in soon so that we can work on those things as separate issues and start gathering data from real-world usage.

@@ -168,7 +169,7 @@ export default function dom ( parsed, source, options ) {
if ( templateProperties.oncreate ) {
builders.init.addBlock( deindent`
if ( options._root ) {
options._root._renderHooks.push({ fn: ${generator.alias( 'template' )}.oncreate, context: this });
options._root._renderHooks.push( ${generator.alias( 'template' )}.oncreate.bind( this ) );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since .bind is slower than pretty much every other option of calling a function later, we could do something like

var self = this;
options._root._renderHooks.push( function () {
  ${ generator.alias( ' template' ) }.oncreate.call ( self );
} );

which would be slightly faster. Here's a test showing this: https://jsperf.com/bind-vs-self-closure (interestingly enough the calling of a .binded function is no slower than the closure in firefox, but in chrome it's 10x slower)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's probably better, yeah. If we wanted to be really clever, the fastest way to run oncreate would probably be to rewrite references to this, the same way we do in non-hoisted event handlers:

// this...
oncreate () {
  this.observe( 'foo', function ( foo ) {
    this.refs.whatever.value = foo.toUpperCase();
  });
}

// ...becomes this:
oncreate ( component ) {
  component.observe( 'foo', function ( foo ) {
    this.refs.whatever.value = foo.toUpperCase();
  });
}

Or is that a terrible idea?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(obviously that only works if oncreate is declared inline — if it's a reference we'd still need to .call it)

next: function () {
transitionManager.running = false;

var now = window.performance.now();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to support older versions of IE? Not sure if there's been a ton of discussion on it, but in this case performance.now() is only supported in IE10+ https://developer.mozilla.org/en-US/docs/Web/API/Performance/now

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, good question. I don't have a strong view. I reckon it's probably ok, since it's easily polyfilled, but perhaps we need to think about having a section of the docs that says which polyfills you'll likely need for which version of IE.

@PaulBGD
Copy link
Member

PaulBGD commented May 1, 2017

I think I got through a third of it, but understanding the logic of it in the browser is tough. I'll pull this out in an IDE soon and take another peek.

@Rich-Harris
Copy link
Member Author

Am basically happy with the state this is in — renamed styles to css in the object returned from transition functions (styles feels like it could be a map of styles rather than a string, and it would be too easy to forget whether it was styles or style), other than that it feels good to go unless anyone has objections.

Last task before merging is to follow up #553 — unfortunately the trick of stringifying functions and comparing the export name with the function name doesn't work for transitionManager. There's no way (that I can think of) to know what it's been renamed to internally, in case of any kind of conflict. Which is vanishingly unlikely, but in the interests of being future-proof I think we need to solve this problem properly once and for all by stringifying the source code for the shared helpers before they get bundled.

@Conduitry
Copy link
Member

shared/_build.js needs to be src/shared/_build.js in the npm scripts. And for the CI scripts, we need to make sure that file is built before eslint runs, doesn't look like that would be happening currently.

@codecov-io
Copy link

codecov-io commented May 3, 2017

Codecov Report

❗ No coverage uploaded for pull request base (master@8ff66f3). Click here to learn what that means.
The diff coverage is 63.86%.

Impacted file tree graph

@@           Coverage Diff            @@
##             master    #525   +/-   ##
========================================
  Coverage          ?   88.2%           
========================================
  Files             ?      95           
  Lines             ?    2933           
  Branches          ?       0           
========================================
  Hits              ?    2587           
  Misses            ?     346           
  Partials          ?       0
Impacted Files Coverage Δ
src/generators/dom/visitors/Component/Component.js 100% <ø> (ø)
src/utils/deindent.js 100% <100%> (ø)
src/parse/state/tag.js 98.02% <100%> (ø)
src/generators/Generator.js 96.31% <100%> (ø)
src/validate/js/propValidators/index.js 100% <100%> (ø)
src/generators/dom/index.js 98.37% <100%> (ø)
src/generators/dom/preprocess.js 100% <100%> (ø)
src/generators/dom/visitors/Element/Element.js 98.5% <100%> (ø)
src/generators/dom/visitors/EachBlock.js 98.73% <100%> (ø)
src/shared/utils.js 100% <100%> (ø)
... and 7 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 8ff66f3...90adb3b. Read the comment docs.

@Conduitry
Copy link
Member

https://travis-ci.org/sveltejs/svelte/jobs/228412243 No destructuring in Node 4 👎

Please. Node 4. Please.

@Rich-Harris
Copy link
Member Author

Not sure what's going on with codecov, will ignore those errors for now

@jslegers
Copy link

jslegers commented Aug 7, 2017

I really don't understand why transitions or easing functions should be part of Svelte.

IMO, the best libraries and frameworks are typically that do as little as possible but do it better than every competitor. IMO transitions are way beyond the scope of a project like Svelte and adding too much of these features in core would actually be a reason for me to avoid using Svelte.

I also don't think that transitions are something that belong in a plugin. Instead, there are the kind of features I'd expect to find in a framework independent utility library that I can plug into React, Vue, or Svelte or whichever framework I'm using.

I'm also not convinced that JS is the right place for parameterizable easing functions that generate CSS. This is precisely the kind of use cases CSS preprocessors are created for. If transitions with custom easing is a feature important to you, why not focus on adding Less or Sass support to Svelte first (#181) and then import transitions & easing from a separate Less or Sass library that can also be plugged into React, Vue & other JS frameworks? See also #549 (comment).

@jslegers jslegers mentioned this pull request Aug 7, 2017
@PaulBGD
Copy link
Member

PaulBGD commented Aug 7, 2017

I'm probably the person on the team who is most against adding features to svelte that don't need to be there, but I disagree. Things like transitions fit well into Svelte because they're things that heavily depend on the state of a component, which gives Svelte the unique advantage of being able to apply them only when needed.

Adding new features to React or Vue causes performance and size implications because you're requiring their entire bundle. You can see React getting around this slightly by previously using require('react/lib/xx') and in the future having multiple modules to include additional features.
Svelte doesn't have this requirement due to the fact that it's a compiler, meaning it doesn't have to include features that it determines you don't need. This means that adding a feature like transitions doesn't slow down or increase bundle size for apps that decide to use a "framework independent utility library".

@jslegers
Copy link

jslegers commented Aug 8, 2017

Svelte doesn't have this requirement due to the fact that it's a compiler, meaning it doesn't have to include features that it determines you don't need. This means that adding a feature like transitions doesn't slow down or increase bundle size for apps that decide to use a "framework independent utility library".

Sure... But as you said elsewhere, this does tend to attract attention away from the core library and adds additional maintenance for a team that probably already has way too little time. This, in turn, prevents the core library from reaching the level of maturity it needs to compete with React or Vue... which itself is needed to attract more users & maintainers alike...

Creating and maintaining plugins alongside the core library is all fine and dandy, but if it distracts away too much attention from the core library it can end up becoming pretty problematic.

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

Successfully merging this pull request may close these issues.

6 participants