Skip to content

Instantly share code, notes, and snippets.

@souporserious
Created October 24, 2017 16:17
Show Gist options
  • Save souporserious/8d288d1217546e32ae08b5935cbb3c3e to your computer and use it in GitHub Desktop.
Save souporserious/8d288d1217546e32ae08b5935cbb3c3e to your computer and use it in GitHub Desktop.
import { Decay, Spring } from './wobble'
import PanResponder from 'universal-panresponder'
const decay = new Decay({ velocity: 0.8 })
decay.start({
fromValue: 12,
onUpdate: value => console.log(value),
})
// react-loco
// const spring = new Spring({ fromValue: 0, toValue: 200 })
// spring.onUpdate(() => console.log(spring.currentValue))
// spring.start()
window.HorizontalPan = (anim, config = {}) => ({
onMouseDown: function(event) {
anim.stopAnimation(startValue => {
config.onStart && config.onStart()
const startPosition = event.clientX
const lastTime = Date.now()
const lastPosition = event.clientX
const velocity = 0
function updateVelocity(event) {
const now = Date.now()
if (event.clientX === lastPosition || now === lastTime) {
return
}
velocity = (event.clientX - lastPosition) / (now - lastTime)
lastTime = now
lastPosition = event.clientX
}
let moveListener, upListener
window.addEventListener(
'mousemove',
(moveListener = event => {
const value = startValue + (event.clientX - startPosition)
anim.setValue(value)
updateVelocity(event)
})
)
window.addEventListener(
'mouseup',
(upListener = event => {
updateVelocity(event)
window.removeEventListener('mousemove', moveListener)
window.removeEventListener('mouseup', upListener)
config.onEnd && config.onEnd({ velocity })
})
)
})
},
})
class PanResponderSample extends Component {
componentWillMount() {
this._panResponder = PanResponder.create({
onStartShouldSetPanResponder: this._handleStartShouldSetPanResponder,
onMoveShouldSetPanResponder: this._handleMoveShouldSetPanResponder,
onPanResponderMove: this._handlePanResponderMove,
onPanResponderRelease: this._handlePanResponderEnd,
onPanResponderTerminate: this._handlePanResponderEnd,
})
this._previousLeft = 0
this._previousTop = 0
}
render() {
return (
<div
ref={node => (this.node = node)}
{...this._panResponder.panHandlers}
{...this.props}
/>
)
}
_handleStartShouldSetPanResponder(e, gestureState) {
return true
}
_handleMoveShouldSetPanResponder(e, gestureState) {
return true
}
_handlePanResponderMove = (e, gestureState) => {
requestAnimationFrame(() => {
this.node.style.transform = `translate3d(${this._previousLeft +
gestureState.dx}px, 0px, 0px)`
})
}
_handlePanResponderEnd = (e, gestureState) => {
this._previousLeft += gestureState.dx
this._previousTop += gestureState.dy
}
}
class DraggableView extends React.Component {
constructor(props) {
super(props)
this.state = {
x: 0,
y: 0,
}
this.state.panResponder = PanResponder.create({
onStartShouldSetPanResponder: (evt, gestureState) => true,
onMoveShouldSetPanResponder: (evt, gestureState) => true,
onPanResponderMove: (e, gestureState) => {
this.setState({
x: gestureState.dx,
// y: gestureState.dy,
})
},
onPanResponderRelease: () => {},
})
}
// https://github.com/animatedjs/animated/issues/58#issuecomment-285654841
render() {
// const styles = {
// transform: Animated.template`
// translateX(${this.state.pan.x}px)
// translateY(${this.state.pan.y}px)
// scale(${this.state.pan.x.interpolate({
// inputRange: [0, 100],
// outputRange: [0.3, 1],
// extrapolate: 'clamp',
// })})
// `,
// }
return (
<div
{...this.state.panResponder.panHandlers}
style={{
transform: `translate3d(${this.state.x}px, ${this.state.y}px, 0px)`,
}}
>
{this.state.x}
{this.state.y}
{this.props.children}
</div>
)
}
}
class Motion extends Component {
springs = {}
createSpring(key, config) {
this.springs[key] = new Spring(config)
}
componentWillMount() {
this.spring = new Spring(this.props)
this.spring.onUpdate(this.handleUpdate)
}
componentDidMount() {
this.spring.start()
}
componentWillReceiveProps(nextProps) {
if (JSON.stringify(this.props) !== JSON.stringify(nextProps)) {
// this.spring = new Spring(nextProps)
}
}
setNode = c => {
this.node = c
}
handleUpdate = () => {
this.node.style.position = 'relative'
this.node.style.transform = this.spring.currentValue + 'px'
}
render() {
return <div ref={this.setNode}>{this.props.children}</div>
}
}
// @flow
import { invariant, withDefault } from './utils'
type SpringConfig = {
fromValue: number, // Starting value of the animation.
toValue: number, // Ending value of the animation.
stiffness: number, // The spring stiffness coefficient.
damping: number, // Defines how the spring’s motion should be damped due to the forces of friction.
mass: number, // The mass of the object attached to the end of the spring.
initialVelocity: number, // The initial velocity (in units/ms) of the object attached to the spring.
allowsOverdamping: boolean,
overshootClamping: boolean,
restVelocityThreshold: number,
restDisplacementThreshold: number,
}
type PartialSpringConfig = $Shape<SpringConfig>
type SpringListenerFn = (spring: Spring) => void
type SpringListener = {
onUpdate?: SpringListenerFn,
onStart?: SpringListenerFn,
onStop?: SpringListenerFn,
}
/**
* Implements a spring physics simulation based on the equations behind
* damped harmonic oscillators (https://en.wikipedia.org/wiki/Harmonic_oscillator#Damped_harmonic_oscillator).
*/
export class Spring {
static MAX_DELTA_TIME_MS = 1 / 60 * 1000 * 4 // advance 4 frames at max
_config: SpringConfig
_listeners: Array<SpringListener> = []
_currentAnimationStep: number = 0 // current requestAnimationFrame
_currentTime: number = 0 // Current timestamp of animation in ms (real time)
_springTime: number = 0 // Current time along the spring curve in ms (zero-based)
_currentValue: number = 0 // the current value of the spring
_currentVelocity: number = 0 // the current velocity of the spring
_isAnimating: boolean = false
_oscillationVelocityPairs = []
constructor(config: PartialSpringConfig = {}) {
this._config = {
fromValue: withDefault(config.fromValue, 0),
toValue: withDefault(config.toValue, 0),
stiffness: withDefault(config.stiffness, 100),
damping: withDefault(config.damping, 10),
mass: withDefault(config.mass, 1),
initialVelocity: withDefault(config.initialVelocity, 0),
overshootClamping: withDefault(config.overshootClamping, false),
allowsOverdamping: withDefault(config.allowsOverdamping, false),
restVelocityThreshold: withDefault(config.restVelocityThreshold, 0.001),
restDisplacementThreshold: withDefault(
config.restDisplacementThreshold,
0.001
),
}
}
/**
* If `fromValue` differs from `toValue`, or `initialVelocity` is non-zero,
* start the simulation and call the `onStart` listeners.
*/
start() {
const { fromValue, toValue, initialVelocity } = this._config
if (fromValue !== toValue || initialVelocity !== 0) {
this._reset()
this._isAnimating = true
if (!this._currentAnimationStep) {
this._notifyListeners('onStart')
this._currentAnimationStep = requestAnimationFrame((t: number) => {
this._step(t)
})
}
}
}
/**
* If a simulation is in progress, stop it and call the `onStop` listeners.
*/
stop() {
if (!this._isAnimating) {
return
}
this._isAnimating = false
this._notifyListeners('onStop')
if (this._currentAnimationStep) {
cancelAnimationFrame(this._currentAnimationStep)
this._currentAnimationStep = 0
}
}
/**
* The spring's current position.
*/
set currentValue(value): number {
this._currentValue = value
}
get currentValue(): number {
return this._currentValue
}
/**
* The spring's current velocity in units / ms.
*/
get currentVelocity(): number {
return this._currentVelocity // give velocity in units/ms;
}
/**
* If the spring has reached its `toValue`, or if its velocity is below the
* `restVelocityThreshold`, it is considered at rest. If `stop()` is called
* during a simulation, both `isAnimating` and `isAtRest` will be false.
*/
get isAtRest(): boolean {
return this._isSpringAtRest()
}
/**
* Whether or not the spring is currently emitting values.
*
* Note: this is distinct from whether or not it is at rest.
* See also `isAtRest`.
*/
get isAnimating(): boolean {
return this._isAnimating
}
/**
* Updates the spring config with the given values. Values not explicitly
* supplied will be reused from the existing config.
*/
updateConfig(updatedConfig: PartialSpringConfig): void {
// When we update the spring config, we reset the simulation to ensure the
// spring always moves the full distance between `fromValue` and `toValue`.
// To ensure that the simulation behaves correctly if those values aren't
// being changed in `updatedConfig`, we run the simulation with `_step()`
// and default `fromValue` and `initialVelocity` to their current values.
this._advanceSpringToTime(performance.now())
const baseConfig = {
fromValue: this._currentValue,
initialVelocity: this._currentVelocity,
}
this._config = {
...this._config,
...baseConfig,
...updatedConfig,
}
this._reset()
}
/**
* The provided callback will be invoked when the simulation begins.
*/
onStart(listener: SpringListenerFn): Spring {
this._listeners.push({ onStart: listener })
return this
}
/**
* The provided callback will be invoked on each frame while the simulation is
* running.
*/
onUpdate(listener: SpringListenerFn): Spring {
this._listeners.push({ onUpdate: listener })
return this
}
/**
* The provided callback will be invoked when the simulation ends.
*/
onStop(listener: SpringListenerFn): Spring {
this._listeners.push({ onStop: listener })
return this
}
/**
* Remove a single listener from this spring.
*/
removeListener(listenerFn: SpringListenerFn): Spring {
this._listeners = this._listeners.reduce((result, listener) => {
const foundListenerFn = Object.values(listener).indexOf(listenerFn) !== -1
if (!foundListenerFn) {
result.push(listener)
}
return result
}, [])
return this
}
/**
* Removes all listeners from this spring.
*/
removeAllListeners(): Spring {
this._listeners = []
return this
}
_reset() {
this._currentTime = 0.0
this._springTime = 0.0
this._currentValue = this._config.fromValue
this._currentVelocity = this._config.initialVelocity
}
_notifyListeners(eventName: $Keys<SpringListener>) {
this._listeners.forEach((listener: SpringListener) => {
const maybeListenerFn = listener[eventName]
if (typeof maybeListenerFn === 'function') {
maybeListenerFn(this)
}
})
}
/**
* `_step` is the main loop. While the animation is running, it updates the
* current state once per frame, and schedules the next frame if the spring is
* not yet at rest.
*/
_step(timestamp: number) {
this._advanceSpringToTime(timestamp, true)
// check `_isAnimating`, in case `stop()` got called during
// `_advanceSpringToTime()`
if (this._isAnimating) {
this._currentAnimationStep = requestAnimationFrame((t: number) =>
this._step(t)
)
}
}
_advanceSpringToTime(
timestamp: number,
shouldNotifyListeners: boolean = false
) {
// `_advanceSpringToTime` updates `_currentTime` and triggers the listeners.
// Because of these side effects, it's only safe to call when an animation
// is already in-progress.
if (!this._isAnimating) {
return
}
const isFirstStep = this._currentTime === 0
if (isFirstStep) {
this._currentTime = timestamp
}
let deltaTime = timestamp - this._currentTime
// If for some reason we lost a lot of frames (e.g. process large payload or
// stopped in the debugger), we only advance by 4 frames worth of
// computation and will continue on the next frame. It's better to have it
// running at slower speed than jumping to the end.
if (deltaTime > Spring.MAX_DELTA_TIME_MS) {
deltaTime = Spring.MAX_DELTA_TIME_MS
}
this._springTime += deltaTime
const c = this._config.damping
const m = this._config.mass
const k = this._config.stiffness
const fromValue = this._config.fromValue
const toValue = this._config.toValue
const v0 = -this._config.initialVelocity
invariant(m > 0, 'Mass value must be greater than 0')
invariant(k > 0, 'Stiffness value must be greater than 0')
invariant(c > 0, 'Damping value must be greater than 0')
let zeta = c / (2 * Math.sqrt(k * m)) // damping ratio (dimensionless)
const omega0 = Math.sqrt(k / m) / 1000 // undamped angular frequency of the oscillator (rad/ms)
const omega1 = omega0 * Math.sqrt(1.0 - zeta * zeta) // exponential decay
const omega2 = omega0 * Math.sqrt(zeta * zeta - 1.0) // frequency of damped oscillation
const x0 = toValue - fromValue // initial displacement of the spring at t = 0
if (zeta > 1 && !this._config.allowsOverdamping) {
zeta = 1
}
let oscillation = 0.0
let velocity = 0.0
const t = this._springTime
if (zeta < 1) {
// Under damped
const envelope = Math.exp(-zeta * omega0 * t)
oscillation =
toValue -
envelope *
((v0 + zeta * omega0 * x0) / omega1 * Math.sin(omega1 * t) +
x0 * Math.cos(omega1 * t))
// This looks crazy -- it's actually just the derivative of the
// oscillation function
velocity =
zeta *
omega0 *
envelope *
(Math.sin(omega1 * t) * (v0 + zeta * omega0 * x0) / omega1 +
x0 * Math.cos(omega1 * t)) -
envelope *
(Math.cos(omega1 * t) * (v0 + zeta * omega0 * x0) -
omega1 * x0 * Math.sin(omega1 * t))
} else if (zeta === 1) {
// Critically damped
const envelope = Math.exp(-omega0 * t)
oscillation = toValue - envelope * (x0 + (v0 + omega0 * x0) * t)
velocity = envelope * (v0 * (t * omega0 - 1) + t * x0 * (omega0 * omega0))
} else {
// Overdamped
const envelope = Math.exp(-zeta * omega0 * t)
oscillation =
toValue -
envelope *
((v0 + zeta * omega0 * x0) * Math.sinh(omega2 * t) +
omega2 * x0 * Math.cosh(omega2 * t)) /
omega2
velocity =
envelope *
zeta *
omega0 *
(Math.sinh(omega2 * t) * (v0 + zeta * omega0 * x0) +
x0 * omega2 * Math.cosh(omega2 * t)) /
omega2 -
envelope *
(omega2 * Math.cosh(omega2 * t) * (v0 + zeta * omega0 * x0) +
omega2 * omega2 * x0 * Math.sinh(omega2 * t)) /
omega2
}
this._currentTime = timestamp
this._currentValue = oscillation
this._currentVelocity = velocity
if (!shouldNotifyListeners) {
return
}
this._notifyListeners('onUpdate')
if (!this._isAnimating) {
// a listener might have stopped us in _onUpdate
return
}
// If the Spring is overshooting (when overshoot clamping is on), or if the
// spring is at rest (based on the thresholds set in the config), stop the
// animation.
if (this._isSpringOvershooting() || this._isSpringAtRest()) {
if (k !== 0) {
// Ensure that we end up with a round value
this._currentValue = toValue
this._currentVelocity = 0
this._notifyListeners('onUpdate')
}
this.stop()
return
}
}
_isSpringOvershooting() {
const { stiffness, fromValue, toValue, overshootClamping } = this._config
let isOvershooting = false
if (overshootClamping && stiffness !== 0) {
if (fromValue < toValue) {
isOvershooting = this._currentValue > toValue
} else {
isOvershooting = this._currentValue < toValue
}
}
return isOvershooting
}
_isSpringAtRest() {
const {
stiffness,
toValue,
restDisplacementThreshold,
restVelocityThreshold,
} = this._config
const isNoVelocity =
Math.abs(this._currentVelocity) <= restVelocityThreshold
const isNoDisplacement =
stiffness !== 0 &&
Math.abs(toValue - this._currentValue) <= restDisplacementThreshold
return isNoDisplacement && isNoVelocity
}
}
export type DecayConfig = {
velocity: number | { x: number, y: number },
deceleration?: number,
}
export type DecayConfigSingle = {
velocity: number,
deceleration?: number,
}
export type DecayStartConfig = {
fromValue: number,
onUpdate: (value: number) => void,
onEnd: ?EndCallback,
}
export class Decay {
_startTime: number
_lastValue: number
_fromValue: number
_deceleration: number
_velocity: number
_onUpdate: (value: number) => void
_animationFrame: any
_useNativeDriver: boolean
constructor({ deceleration = 0.998, velocity }: DecayConfigSingle) {
this._deceleration = deceleration
this._velocity = velocity
}
start({ fromValue, onUpdate, onEnd }: DecayStartConfig): void {
this.__active = true
this._lastValue = fromValue
this._fromValue = fromValue
this._onUpdate = onUpdate
this._onEnd = onEnd
this._startTime = Date.now()
this._animationFrame = requestAnimationFrame(this.onUpdate.bind(this))
}
onUpdate(): void {
const now = Date.now()
const value =
this._fromValue +
this._velocity /
(1 - this._deceleration) *
(1 - Math.exp(-(1 - this._deceleration) * (now - this._startTime)))
this._onUpdate(value)
if (Math.abs(this._lastValue - value) < 0.1) {
return
}
this._lastValue = value
if (this.__active) {
this._animationFrame = requestAnimationFrame(this.onUpdate.bind(this))
}
}
stop(): void {
this.__active = false
cancelAnimationFrame(this._animationFrame)
this._animationFrame = undefined
}
}
// @flow
export function invariant(condition: boolean, message: string): void {
if (!condition) {
throw new Error(message)
}
}
export function withDefault<X>(maybeValue: ?X, defaultValue: X): X {
return typeof maybeValue !== 'undefined'
? ((maybeValue: any): X)
: defaultValue
}
@souporserious
Copy link
Author

<Motion
  fromValue={{
    x: 0,
    y: 0,
  }}
  toValue={{
    x: 200,
    y: 100,
  }}
  mapValue={interpolated => ({
    transform: `translate3d(${interpolated.x}px, ${interpolated.y}px, 0px)`,
  })}
  stiffness={300}
  damping={10}
>
  Content
</Motion>

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