Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
Create unique animations and interactions with tweens, physics and input tracking.

Popmotion is:
- **Tiny:** At ~9kb, it's 75% smaller than GreenSock.
- **Tiny:** At ~10kb, it's 75% smaller than GreenSock.
- **Fast:** Stands up to popular alternatives in [performance tests](http://codepen.io/popmotion/pen/zNYXmR).
- **Compatible:** Full browser support and preloaded with CSS, SVG and SVG path renderers.
- **Composable:** Actions and output functions can be composed to [create complex motion systems](http://codepen.io/popmotion/pen/EZaPxZ).
Expand Down
47 changes: 47 additions & 0 deletions docs/api/action/spring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
title: Spring
description: A UI spring simulation, based on Apple's CASpringAnimation.
category: action
---

# Spring

A highly-accurate spring simulation based on Apple's `CASpringAnimation` implementation.

This simulation offers greater variety and accuracy than the basic spring equations in [`physics`](/api/physics).

`spring(props <Object>)`

## Props
- `stiffness <Number>`: The spring stiffness (default: `100`)
- `damping <Number>`: The strength of the friction force used to dampen motion (default: `10`)
- `mass <Number>`: Mass of the moving object. (default: `1.0`)
- `velocity <Number>`: The initial velocity of the spring. (default: `0.0`)
- `from <Number>`: Start from this number. (default `0`)
- `to <Number>`: End at this number. (default `0`)
- `restDisplacement <Number>`: End the animation if the distance to target is below this value (and `restSpeed`) (default: `0.01`)
- `restSpeed <Number>`: End the animation if the speed drops below this value (and `restDisplacement`) (default: `0.01`)

## Methods

[...Action](/api/action)

## Playground

```javascript
import { spring } from 'popmotion';
```

```marksy
<Example template="Ball">{`
const ball = document.querySelector('.ball');
const ballRenderer = css(ball);

spring({
mass: 2,
stiffness: 1000,
damping: 50,
to: 300
}).start();
`}</Example>
```
5 changes: 5 additions & 0 deletions packages/popmotion-react/stories/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { linkTo } from '@storybook/addon-links';
import DragSingleChild from './react/DragSingleChild';
import TransitionGroup from './react/TransitionGroup';
import Toggle from './popmotion/Toggle';
import Spring from './popmotion/Spring';
import SpinnableDom from './spinnable/SpinnableDOM';
import SpinnableSvg from './spinnable/SpinnableSVG';
import Timeline from './timeline/Timeline';
Expand Down Expand Up @@ -37,5 +38,9 @@ storiesOf('timeline').add('Timeline stagger', () => <TimelineStagger />);
// Draggable
storiesOf('draggable').add('Drag XY', () => <DragXY />);

// Spring
storiesOf('spring').add('Spring', () => <Spring />);


// Inertia
//storiesOf('inertia').add('Throw to inertia', () => <Inertia />);
41 changes: 41 additions & 0 deletions packages/popmotion-react/stories/popmotion/Spring.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React from 'react';
import { spring } from 'popmotion';
import { MotionValue } from '../../src';

const springProps = {
stiffness: 10000,
damping: 10,
mass: 10,
velocity: 1000
};

export default () => (
<MotionValue
onStateChange={{
on: ({value}) => spring({
from: value.get(),
to: 300,
...springProps,
onUpdate: value
}).start(),
off: ({value}) => spring({
from: value.get(),
to: 0,
...springProps,
onUpdate: value
}).start()
}}
>
{({ v, state, setStateTo }) => (
<div
style={{
background: 'red',
width: '100px',
height: '100px',
transform: 'translateX(' + v + 'px)'
}}
onClick={state === 'on' ? setStateTo.off : setStateTo.on}
/>
)}
</MotionValue>
);
2 changes: 2 additions & 0 deletions site/components/examples/Example.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
trackOffset,
tween,
stagger,
spring,
value,
Renderer,
css,
Expand Down Expand Up @@ -102,6 +103,7 @@ export default ({ children, template, id, isReactComponent=false }) => {
pointer,
trackOffset,
tween,
spring,
stagger,
value,
Renderer,
Expand Down
2 changes: 1 addition & 1 deletion site/templates/homepage/USPs.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export default () => (
</USP>
<USP>
<Header>Tiny</Header>
<Description>At ~9kb, it's <Strong>75% smaller</Strong> than Greensock TweenMax. Great for users on slower connections.</Description>
<Description>At ~10kb, it's <Strong>75% smaller</Strong> than Greensock TweenMax. Great for users on slower connections.</Description>
</USP>
<USP>
<Header>Blazing Fast</Header>
Expand Down
93 changes: 93 additions & 0 deletions src/actions/spring.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
The closed-form damped harmonic oscillating spring.
Or, spring.

This is a direct port of Adam Miskiewicz's (@skevy) React Animated
PR #15322 https://github.com/facebook/react-native/pull/15322/

```
spring({
mass: 2,
damping: 10,
stiffness: 100,
to: 100
}).start();
```

Adam Miskiewicz:
@skevy (twitter.com/skevy, github.com/skevy)
*/
import Action from './';
import { timeSinceLastFrame } from '../framesync';

class Spring extends Action {
static defaultProps = {
stiffness: 100,
damping: 10,
mass: 1.0,
velocity: 0.0,
from: 0.0,
to: 0.0,
restSpeed: 0.01,
restDisplacement: 0.01
};

onStart() {
const { velocity, to } = this.props;
this.t = 0;
this.initialVelocity = velocity / 1000;

@mattgperry mattgperry Aug 5, 2017

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@skevy Popmotion uses per-second measurements for velocity. Before I divided by 1000 the spring would jump to its to value, afterwards it worked nicely. The 1000 is an assumption that React Animated must be using per-millisecond velocities - is this correct, or is there a better way for me to convert per-second to the velocity expected by the simulation?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

What's an example set of values you were using (before you divided v0 by 1000? I coded the velocity in Animated to also be per second...for example I usually get values for velocity in the 0-10 (+/-) px/sec range. This whole function is calculated in per second values (t is in seconds, thus dt (velocity) is also per second).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I've been using your example of { stiffness: 1000, damping: 500, mass: 3 } as a control, as I know that's a spring that's meant to look normal.

The velocity calculation in this update loop does seem to output values +/-0-10 and it works fine if I feed this value back into the next spring (like React Animated).

However we use a standard velocity calculation across every action for interoperability: speedPerSecond(current - prev, timeDelta)

The numbers I've historically received from that are closer to +/-500-5000 unit/sec. This kind of magnitude makes more sense to me because if we're making a spring that moves from 0 - 800 and it takes ~ half a second to do so, then at it's fastest you're expecting a velocity of at least ~ 1600 px/sec rather than something in the 10s.

this.isComplete = false;
this.delta = to - this.from;
}

update() {
const { stiffness, damping, mass, from, to, restSpeed, restDisplacement } = this.props;
const { delta, initialVelocity } = this;

const timeDelta = timeSinceLastFrame() / 1000;

@mattgperry mattgperry Aug 5, 2017

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@skevy We divide by 1000 here, this originally confused me as at first glance I thought the original PR was dividing by 1000 to get a ms value. Then I realised it looks like you're dividing milliseconds by 1000? When I applied this, the spring worked! Is there a reason for this? I'm confused about the units here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

We want t to be in units per sec. If were to just let this loop run with no animation inside of it and log t, you'd see it just counts up by fractions of a second.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Ah I see - I naturally think about this value in milliseconds, this makes sense if t is in seconds.

const t = this.t = this.t + timeDelta;

const dampingRatio = damping / (2 * Math.sqrt(stiffness * mass));
const angularFreq = Math.sqrt(stiffness / mass);
const expoDecay = angularFreq * Math.sqrt(Math.abs(1.0 - (dampingRatio * dampingRatio)));

@mattgperry mattgperry Aug 5, 2017

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@skevy I added Math.abs here as with certain combinations of damping and stiffness (inc the 500/1000 in your original example) dampingRatio * dampingRatio came out as more than 1.0, which then lead to a negative number (throwing a NaN with sqrt)

This makes me think there's an error in my implementation, but I can't find it? Is this expected?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The error is in your "critically damped" branch (I pointed it out below)...expoDecay is only used when the spring is underdamped (in which case the math will work out correctly, because dampingRatio^2 will be less than one).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yep fixing this sorted it, thanks!


const x0 = 1;
let oscillation = 0.0;
let velocity = 0.0;

// Underdamped
if (dampingRatio < 1) {
const envelope = Math.exp(-dampingRatio * angularFreq * t);
oscillation = envelope * (((initialVelocity + dampingRatio * angularFreq * x0) / expoDecay) * Math.sin(expoDecay * t) + (x0 * Math.cos(expoDecay * t)));
velocity = (envelope * ((Math.cos(expoDecay * t) * (initialVelocity + dampingRatio * angularFreq * x0)) - (expoDecay * x0 * Math.sin(expoDecay * t))) -
((dampingRatio * angularFreq * envelope) * ((((Math.sin(expoDecay * t) * (initialVelocity + dampingRatio * angularFreq * x0)) ) / expoDecay) + (x0 * Math.cos(expoDecay * t)))));
// Critically damped
} else {
const envelope = Math.exp(-expoDecay * t);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This should be Math.exp(-angularFreq * t)

oscillation = envelope * (x0 + (initialVelocity + (expoDecay * x0)) * t);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should be oscillation = envelope * (x0 + (initialVelocity + (angularFreq * x0)) * t);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Doh! I was so concerned with finding an error up till that abs line that I didn't check later on. Thanks for your clear explanations, I'll take a look and make them amendments.

velocity = envelope * ((t * initialVelocity * angularFreq) - (t * x0 * (angularFreq * angularFreq)) + initialVelocity);
}

const fraction = 1 - oscillation;
let position = from + fraction * delta;

// Check if simulation is complete
// We do this here instead of `isActionComplete` as it allows us
// to clamp to end during update)
const isBelowVelocityThreshold = Math.abs(velocity) <= restSpeed;
const isBelowDisplacementThreshold = Math.abs(to - position) <= restDisplacement;
this.isComplete = isBelowVelocityThreshold && isBelowDisplacementThreshold;

if (this.isComplete) {
position = to;
}

return position;
}

isActionComplete() {
return this.isComplete;
}
}

export default (props) => new Spring(props);
1 change: 1 addition & 0 deletions src/popmotion.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export pointer from './actions/pointer';
export trackOffset from './actions/track-offset';
export tween from './actions/tween';
export stagger from './actions/stagger';
export spring from './actions/spring';
export value from './actions/value';

// Renderers
Expand Down