diff --git a/jest/Animated/AnimatedImplementation.js b/jest/Animated/AnimatedImplementation.js new file mode 100755 index 00000000000000..9d7b3b4e5cfa41 --- /dev/null +++ b/jest/Animated/AnimatedImplementation.js @@ -0,0 +1,1191 @@ +import invariant from 'invariant'; +import Interpolation from './Interpolation'; +import Easing from './Easing'; +import InteractionManager from '../InteractionManager'; +import SpringConfig from './SpringConfig'; +import requestAnimationFrame from 'raf'; + +function flattenStyle(style) { + if (!style) { + return undefined; + } + if (!Array.isArray(style)) { + return style; + } + return Object.assign({}, ...style); +} + +class Animated { + __attach() {} + __detach() {} + __getValue() {} + __getAnimatedValue() { return this.__getValue(); } + __addChild(child) {} + __removeChild(child) {} + __getChildren() { return []; } +} + +class Animation { + start(fromValue, onUpdate, onEnd, previousAnimation) {} + stop() {} + __debouncedOnEnd(result) { + const onEnd = this.__onEnd; + this.__onEnd = null; + if (onEnd) { + onEnd(result); + } + } +} + +class AnimatedWithChildren extends Animated { + constructor() { + super(); + this._children = []; + } + + __addChild(child) { + if (this._children.length === 0) { + this.__attach(); + } + this._children.push(child); + } + + __removeChild(child) { + const index = this._children.indexOf(child); + if (index === -1) { + console.warn( + 'Trying to remove a child that doesn\'t exist' + ); + return; + } + this._children.splice(index, 1); + if (this._children.length === 0) { + this.__detach(); + } + } + + __getChildren() { + return this._children; + } +} + +/** + * Animated works by building a directed acyclic graph of dependencies + * transparently when you render your Animated components. + * + * new Animated.Value(0) + * .interpolate() .interpolate() new Animated.Value(1) + * opacity translateY scale + * style transform + * View#234 style + * View#123 + * + * A) Top Down phase + * When an Animated.Value is updated, we recursively go down through this + * graph in order to find leaf nodes: the views that we flag as needing + * an update. + * + * B) Bottom Up phase + * When a view is flagged as needing an update, we recursively go back up + * in order to build the new value that it needs. The reason why we need + * this two-phases process is to deal with composite props such as + * transform which can receive values from multiple parents. + */ +function _flush(rootNode) { + const animatedStyles = new Set(); + function findAnimatedStyles(node) { + if (typeof node.update === 'function') { + animatedStyles.add(node); + } else { + node.__getChildren().forEach(findAnimatedStyles); + } + } + findAnimatedStyles(rootNode); + animatedStyles.forEach(animatedStyle => animatedStyle.update()); +} + +const easeInOut = Easing.inOut(Easing.ease); + +class TimingAnimation extends Animation { + constructor(config) { + super(); + this._toValue = config.toValue; + this._easing = config.easing || easeInOut; + this._duration = config.duration !== undefined ? config.duration : 500; + this._delay = config.delay || 0; + this.__isInteraction = config.isInteraction !== undefined ? config.isInteraction : true; + } + + start(fromValue, onUpdate, onEnd) { + this.__active = true; + this._fromValue = fromValue; + this._onUpdate = onUpdate; + this.__onEnd = onEnd; + + const start = () => { + if (this._duration === 0) { + this._onUpdate(this._toValue); + this.__debouncedOnEnd({ finished: true }); + } else { + this._startTime = Date.now(); + this._animationFrame = requestAnimationFrame(this.onUpdate.bind(this)); + } + }; + if (this._delay) { + this._timeout = setTimeout(start, this._delay); + } else { + start(); + } + } + + onUpdate() { + const now = Date.now(); + if (now >= this._startTime + this._duration) { + if (this._duration === 0) { + this._onUpdate(this._toValue); + } else { + this._onUpdate( + this._fromValue + this._easing(1) * (this._toValue - this._fromValue) + ); + } + this.__debouncedOnEnd({ finished: true }); + return; + } + + this._onUpdate( + this._fromValue + + this._easing((now - this._startTime) / this._duration) * + (this._toValue - this._fromValue) + ); + if (this.__active) { + this._animationFrame = requestAnimationFrame(this.onUpdate.bind(this)); + } + } + + stop() { + this.__active = false; + clearTimeout(this._timeout); + if (global && global.cancelAnimationFrame) { + global.cancelAnimationFrame(this._animationFrame); + } + this.__debouncedOnEnd({ finished: false }); + } +} + +class DecayAnimation extends Animation { + constructor(config) { + super(); + this._deceleration = config.deceleration || 0.998; + this._velocity = config.velocity; + this.__isInteraction = config.isInteraction !== undefined ? config.isInteraction : true; + } + + start(fromValue, onUpdate, onEnd) { + 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() { + 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) { + this.__debouncedOnEnd({ finished: true }); + return; + } + + this._lastValue = value; + if (this.__active) { + this._animationFrame = requestAnimationFrame(this.onUpdate.bind(this)); + } + } + + stop() { + this.__active = false; + if (global && global.cancelAnimationFrame) { + global.cancelAnimationFrame(this._animationFrame); + } + this.__debouncedOnEnd({ finished: false }); + } +} + +function withDefault(value, defaultValue) { + if (value === undefined || value === null) { + return defaultValue; + } + return value; +} + +class SpringAnimation extends Animation { + constructor(config) { + super(); + + this._overshootClamping = withDefault(config.overshootClamping, false); + this._restDisplacementThreshold = withDefault(config.restDisplacementThreshold, 0.001); + this._restSpeedThreshold = withDefault(config.restSpeedThreshold, 0.001); + this._initialVelocity = config.velocity; + this._lastVelocity = withDefault(config.velocity, 0); + this._toValue = config.toValue; + this.__isInteraction = config.isInteraction !== undefined ? config.isInteraction : true; + + let springConfig; + if (config.bounciness !== undefined || config.speed !== undefined) { + invariant( + config.tension === undefined && config.friction === undefined, + 'You can only define bounciness/speed or tension/friction but not both' + ); + springConfig = SpringConfig.fromBouncinessAndSpeed( + withDefault(config.bounciness, 8), + withDefault(config.speed, 12), + ); + } else { + springConfig = SpringConfig.fromOrigamiTensionAndFriction( + withDefault(config.tension, 40), + withDefault(config.friction, 7), + ); + } + this._tension = springConfig.tension; + this._friction = springConfig.friction; + } + + start(fromValue, onUpdate, onEnd, previousAnimation) { + this.__active = true; + this._startPosition = fromValue; + this._lastPosition = this._startPosition; + + this._onUpdate = onUpdate; + this.__onEnd = onEnd; + this._lastTime = Date.now(); + + if (previousAnimation instanceof SpringAnimation) { + const internalState = previousAnimation.getInternalState(); + this._lastPosition = internalState.lastPosition; + this._lastVelocity = internalState.lastVelocity; + this._lastTime = internalState.lastTime; + } + if (this._initialVelocity !== undefined && this._initialVelocity !== null) { + this._lastVelocity = this._initialVelocity; + } + this.onUpdate(); + } + + getInternalState() { + return { + lastPosition: this._lastPosition, + lastVelocity: this._lastVelocity, + lastTime: this._lastTime, + }; + } + + onUpdate() { + let position = this._lastPosition; + let velocity = this._lastVelocity; + + let tempPosition = this._lastPosition; + let tempVelocity = this._lastVelocity; + + // 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 faster speed than jumping to the end. + const MAX_STEPS = 64; + let now = Date.now(); + if (now > this._lastTime + MAX_STEPS) { + now = this._lastTime + MAX_STEPS; + } + + // We are using a fixed time step and a maximum number of iterations. + // The following post provides a lot of thoughts into how to build this + // loop: http://gafferongames.com/game-physics/fix-your-timestep/ + const TIMESTEP_MSEC = 1; + const numSteps = Math.floor((now - this._lastTime) / TIMESTEP_MSEC); + + for (let i = 0; i < numSteps; ++i) { + // Velocity is based on seconds instead of milliseconds + const step = TIMESTEP_MSEC / 1000; + + // This is using RK4. A good blog post to understand how it works: + // http://gafferongames.com/game-physics/integration-basics/ + const aVelocity = velocity; + const aAcceleration = + this._tension * (this._toValue - tempPosition) - this._friction * tempVelocity; + tempPosition = position + aVelocity * step / 2; + tempVelocity = velocity + aAcceleration * step / 2; + + const bVelocity = tempVelocity; + const bAcceleration = + this._tension * (this._toValue - tempPosition) - this._friction * tempVelocity; + tempPosition = position + bVelocity * step / 2; + tempVelocity = velocity + bAcceleration * step / 2; + + const cVelocity = tempVelocity; + const cAcceleration = + this._tension * (this._toValue - tempPosition) - this._friction * tempVelocity; + tempPosition = position + cVelocity * step / 2; + tempVelocity = velocity + cAcceleration * step / 2; + + const dVelocity = tempVelocity; + const dAcceleration = + this._tension * (this._toValue - tempPosition) - this._friction * tempVelocity; + tempPosition = position + cVelocity * step / 2; + tempVelocity = velocity + cAcceleration * step / 2; + + const dxdt = (aVelocity + 2 * (bVelocity + cVelocity) + dVelocity) / 6; + const dvdt = (aAcceleration + 2 * (bAcceleration + cAcceleration) + dAcceleration) / 6; + + position += dxdt * step; + velocity += dvdt * step; + } + + this._lastTime = now; + this._lastPosition = position; + this._lastVelocity = velocity; + + this._onUpdate(position); + if (!this.__active) { // a listener might have stopped us in _onUpdate + return; + } + + // Conditions for stopping the spring animation + let isOvershooting = false; + if (this._overshootClamping && this._tension !== 0) { + if (this._startPosition < this._toValue) { + isOvershooting = position > this._toValue; + } else { + isOvershooting = position < this._toValue; + } + } + + const isVelocity = Math.abs(velocity) <= this._restSpeedThreshold; + let isDisplacement = true; + if (this._tension !== 0) { + isDisplacement = Math.abs(this._toValue - position) <= this._restDisplacementThreshold; + } + + if (isOvershooting || (isVelocity && isDisplacement)) { + if (this._tension !== 0) { + // Ensure that we end up with a round value + this._onUpdate(this._toValue); + } + + this.__debouncedOnEnd({ finished: true }); + return; + } + this._animationFrame = requestAnimationFrame(this.onUpdate.bind(this)); + } + + stop() { + this.__active = false; + window.cancelAnimationFrame(this._animationFrame); + this.__debouncedOnEnd({ finished: false }); + } +} + +let _uniqueId = 1; + +class AnimatedInterpolation extends AnimatedWithChildren { + constructor(parent, interpolation) { + super(); + this._parent = parent; + this._interpolation = interpolation; + } + + __getValue() { + const parentValue = this._parent.__getValue(); + invariant( + typeof parentValue === 'number', + 'Cannot interpolate an input which is not a number.' + ); + return this._interpolation(parentValue); + } + + interpolate(config) { + return new AnimatedInterpolation(this, Interpolation.create(config)); + } + + __attach() { + this._parent.__addChild(this); + } + + __detach() { + this._parent.__removeChild(this); + } +} + + +class AnimatedValue extends AnimatedWithChildren { + constructor(value) { + super(); + this._value = value; + this._offset = 0; + this._animation = null; + this._listeners = {}; + } + + __detach() { + this.stopAnimation(); + } + + __getValue() { + return this._value + this._offset; + } + + /** + * Directly set the value. This will stop any animations running on the value + * and update all the bound properties. + */ + setValue(value) { + if (this._animation) { + this._animation.stop(); + this._animation = null; + } + this._updateValue(value); + } + + /** + * Sets an offset that is applied on top of whatever value is set, whether via + * `setValue`, an animation, or `Animated.event`. Useful for compensating + * things like the start of a pan gesture. + */ + setOffset(offset) { + this._offset = offset; + } + + /** + * Merges the offset value into the base value and resets the offset to zero. + * The final output of the value is unchanged. + */ + flattenOffset() { + this._value += this._offset; + this._offset = 0; + } + + /** + * Adds an asynchronous listener to the value so you can observe updates from + * animations. This is useful because there is no way to + * synchronously read the value because it might be driven natively. + */ + addListener(callback) { + const id = String(_uniqueId++); + this._listeners[id] = callback; + return id; + } + + removeListener(id) { + delete this._listeners[id]; + } + + removeAllListeners() { + this._listeners = {}; + } + + /** + * Stops any running animation or tracking. `callback` is invoked with the + * final value after stopping the animation, which is useful for updating + * state to match the animation position with layout. + */ + stopAnimation(callback) { + this.stopTracking(); + if (this._animation) { + this._animation.stop(); + } + this._animation = null; + if (callback) { + callback(this.__getValue()); + } + } + + /** + * Interpolates the value before updating the property, e.g. mapping 0-1 to + * 0-10. + */ + interpolate(config) { + return new AnimatedInterpolation(this, Interpolation.create(config)); + } + + /** + * Typically only used internally, but could be used by a custom Animation + * class. + */ + animate(animation, callback) { + let handle = null; + if (animation.__isInteraction) { + handle = InteractionManager.createInteractionHandle(); + } + const previousAnimation = this._animation; + if (this._animation) { + this._animation.stop(); + } + this._animation = animation; + animation.start( + this._value, + (value) => { + this._updateValue(value); + }, + (result) => { + this._animation = null; + if (handle !== null) { + InteractionManager.clearInteractionHandle(handle); + } + if (callback) { + callback(result); + } + }, + previousAnimation + ); + } + + /** + * Typically only used internally. + */ + stopTracking() { + if (this._tracking) { + this._tracking.__detach(); + } + this._tracking = null; + } + + /** + * Typically only used internally. + */ + track(tracking) { + this.stopTracking(); + this._tracking = tracking; + } + + _updateValue(value) { + this._value = value; + _flush(this); + for (const key in this._listeners) { + this._listeners[key]({ value: this.__getValue() }); + } + } +} + + +class AnimatedValueXY extends AnimatedWithChildren { + constructor(valueIn) { + super(); + const value = valueIn || { x: 0, y: 0 }; // @flowfixme: shouldn't need `: any` + if (typeof value.x === 'number' && typeof value.y === 'number') { + this.x = new AnimatedValue(value.x); + this.y = new AnimatedValue(value.y); + } else { + invariant( + value.x instanceof AnimatedValue && + value.y instanceof AnimatedValue, + 'AnimatedValueXY must be initalized with an object of numbers or ' + + 'AnimatedValues.' + ); + this.x = value.x; + this.y = value.y; + } + this._listeners = {}; + } + + setValue(value) { + this.x.setValue(value.x); + this.y.setValue(value.y); + } + + setOffset(offset) { + this.x.setOffset(offset.x); + this.y.setOffset(offset.y); + } + + flattenOffset() { + this.x.flattenOffset(); + this.y.flattenOffset(); + } + + __getValue() { + return { + x: this.x.__getValue(), + y: this.y.__getValue(), + }; + } + + stopAnimation(callback) { + this.x.stopAnimation(); + this.y.stopAnimation(); + if (callback) { + callback(this.__getValue()); + } + } + + addListener(callback) { + const id = String(_uniqueId++); + const jointCallback = ({ value }) => { + callback(this.__getValue()); + }; + this._listeners[id] = { + x: this.x.addListener(jointCallback), + y: this.y.addListener(jointCallback), + }; + return id; + } + + removeListener(id) { + this.x.removeListener(this._listeners[id].x); + this.y.removeListener(this._listeners[id].y); + delete this._listeners[id]; + } + + /** + * Converts `{x, y}` into `{left, top}` for use in style, e.g. + * + *```javascript + * style={this.state.anim.getLayout()} + *``` + */ + getLayout() { + return { + left: this.x, + top: this.y, + }; + } + + /** + * Converts `{x, y}` into a useable translation transform, e.g. + * + *```javascript + * style={{ + * transform: this.state.anim.getTranslateTransform() + * }} + *``` + */ + getTranslateTransform() { + return [ + { translateX: this.x }, + { translateY: this.y } + ]; + } +} + + +class AnimatedAddition extends AnimatedWithChildren { + constructor(a, b) { + super(); + this._a = a; + this._b = b; + } + + __getValue() { + return this._a.__getValue() + this._b.__getValue(); + } + + interpolate(config) { + return new AnimatedInterpolation(this, Interpolation.create(config)); + } + + __attach() { + this._a.__addChild(this); + this._b.__addChild(this); + } + + __detach() { + this._a.__removeChild(this); + this._b.__removeChild(this); + } +} + +class AnimatedMultiplication extends AnimatedWithChildren { + constructor(a, b) { + super(); + this._a = a; + this._b = b; + } + + __getValue() { + return this._a.__getValue() * this._b.__getValue(); + } + + interpolate(config) { + return new AnimatedInterpolation(this, Interpolation.create(config)); + } + + __attach() { + this._a.__addChild(this); + this._b.__addChild(this); + } + + __detach() { + this._a.__removeChild(this); + this._b.__removeChild(this); + } +} + +class AnimatedTransform extends AnimatedWithChildren { + constructor(transforms) { + super(); + this._transforms = transforms; + } + + __getValue() { + return this._transforms.map(transform => { + const result = {}; + for (const key in transform) { + const value = transform[key]; + if (value instanceof Animated) { + result[key] = value.__getValue(); + } else { + result[key] = value; + } + } + return result; + }); + } + + __getAnimatedValue() { + return this._transforms.map(transform => { + const result = {}; + for (const key in transform) { + const value = transform[key]; + if (value instanceof Animated) { + result[key] = value.__getAnimatedValue(); + } else { + // All transform components needed to recompose matrix + result[key] = value; + } + } + return result; + }); + } + + __attach() { + this._transforms.forEach(transform => { + for (const key in transform) { + const value = transform[key]; + if (value instanceof Animated) { + value.__addChild(this); + } + } + }); + } + + __detach() { + this._transforms.forEach(transform => { + for (const key in transform) { + const value = transform[key]; + if (value instanceof Animated) { + value.__removeChild(this); + } + } + }); + } +} + +class AnimatedStyle extends AnimatedWithChildren { + constructor(style) { + super(); + let newStyle; + newStyle = flattenStyle(style) || {}; + if (newStyle.transform) { + newStyle = { + ...newStyle, + transform: new AnimatedTransform(newStyle.transform), + }; + } + this._style = newStyle; + } + + __getValue() { + const style = {}; + for (const key in this._style) { + const value = this._style[key]; + if (value instanceof Animated) { + style[key] = value.__getValue(); + } else { + style[key] = value; + } + } + return style; + } + + __getAnimatedValue() { + const style = {}; + for (const key in this._style) { + const value = this._style[key]; + if (value instanceof Animated) { + style[key] = value.__getAnimatedValue(); + } + } + return style; + } + + __attach() { + for (const key in this._style) { + const value = this._style[key]; + if (value instanceof Animated) { + value.__addChild(this); + } + } + } + + __detach() { + for (const key in this._style) { + const value = this._style[key]; + if (value instanceof Animated) { + value.__removeChild(this); + } + } + } +} + +class AnimatedProps extends Animated { + constructor(props, callback) { + super(); + this._props = props; + if (this._props.style) { + this._props = { + ...this._props, + style: new AnimatedStyle(this._props.style), + }; + } + this._callback = callback; + this.__attach(); + } + + __getValue() { + const props = {}; + for (const key in this._props) { + const value = this._props[key]; + if (value instanceof Animated) { + props[key] = value.__getValue(); + } else { + props[key] = value; + } + } + return props; + } + + __getAnimatedValue() { + const props = {}; + for (const key in this._props) { + const value = this._props[key]; + if (value instanceof Animated) { + props[key] = value.__getAnimatedValue(); + } + } + return props; + } + + __attach() { + for (const key in this._props) { + const value = this._props[key]; + if (value instanceof Animated) { + value.__addChild(this); + } + } + } + + __detach() { + for (const key in this._props) { + const value = this._props[key]; + if (value instanceof Animated) { + value.__removeChild(this); + } + } + } + + update() { + this._callback(); + } +} + +class AnimatedTracking extends Animated { + constructor(value, parent, animationClass, animationConfig, callback) { + super(); + this._value = value; + this._parent = parent; + this._animationClass = animationClass; + this._animationConfig = animationConfig; + this._callback = callback; + this.__attach(); + } + + __getValue() { + return this._parent.__getValue(); + } + + __attach() { + this._parent.__addChild(this); + } + + __detach() { + this._parent.__removeChild(this); + } + + update() { + this._value.animate(new this._animationClass({ + ...this._animationConfig, + toValue: this._animationConfig.toValue.__getValue(), + }), this._callback); + } +} + +function add(a, b) { + return new AnimatedAddition(a, b); +} + +function multiply(a, b) { + return new AnimatedMultiplication(a, b); +} + +function parallel(animations, config) { + let doneCount = 0; + // Make sure we only call stop() at most once for each animation + const hasEnded = {}; + const stopTogether = !(config && config.stopTogether === false); + + const result = { + start(callback) { + if (doneCount === animations.length) { + if (callback) { + callback({ finished: true }); + } + return; + } + + animations.forEach((animation, idx) => { + const cb = function (endResult) { + hasEnded[idx] = true; + doneCount++; + if (doneCount === animations.length) { + doneCount = 0; + if (callback) { + callback(endResult); + } + return; + } + + if (!endResult.finished && stopTogether) { + result.stop(); + } + }; + + if (!animation) { + cb({ finished: true }); + } else { + animation.start(cb); + } + }); + }, + + stop() { + animations.forEach((animation, idx) => { + if (!hasEnded[idx]) { + animation.stop(); + } + hasEnded[idx] = true; + }); + } + }; + + return result; +} + +function maybeVectorAnim(value, config, anim) { + if (value instanceof AnimatedValueXY) { + const configX = { ...config }; + const configY = { ...config }; + for (const key in config) { + const { x, y } = config[key]; + if (x !== undefined && y !== undefined) { + configX[key] = x; + configY[key] = y; + } + } + const aX = anim(value.x, configX); + const aY = anim(value.y, configY); + // We use `stopTogether: false` here because otherwise tracking will break + // because the second animation will get stopped before it can update. + return parallel([aX, aY], { stopTogether: false }); + } + return null; +} + +function spring(value, config) { + return maybeVectorAnim(value, config, spring) || { + start(callback) { + const singleValue = value; + const singleConfig = config; + singleValue.stopTracking(); + if (config.toValue instanceof Animated) { + singleValue.track(new AnimatedTracking( + singleValue, + config.toValue, + SpringAnimation, + singleConfig, + callback + )); + } else { + singleValue.animate(new SpringAnimation(singleConfig), callback); + } + }, + + stop() { + value.stopAnimation(); + }, + }; +} + +function timing(value, config) { + return maybeVectorAnim(value, config, timing) || { + start(callback) { + const singleValue = value; + const singleConfig = config; + singleValue.stopTracking(); + if (config.toValue instanceof Animated) { + singleValue.track(new AnimatedTracking( + singleValue, + config.toValue, + TimingAnimation, + singleConfig, + callback + )); + } else { + singleValue.animate(new TimingAnimation(singleConfig), callback); + } + }, + + stop() { + value.stopAnimation(); + }, + }; +} + +function decay(value, config) { + return maybeVectorAnim(value, config, decay) || { + start(callback) { + const singleValue = value; + const singleConfig = config; + singleValue.stopTracking(); + singleValue.animate(new DecayAnimation(singleConfig), callback); + }, + + stop() { + value.stopAnimation(); + }, + }; +} + +function sequence(animations) { + let current = 0; + return { + start(callback) { + const onComplete = function (result) { + if (!result.finished) { + if (callback) { + callback(result); + } + return; + } + + current++; + + if (current === animations.length) { + if (callback) { + callback(result); + } + return; + } + + animations[current].start(onComplete); + }; + + if (animations.length === 0) { + if (callback) { + callback({ finished: true }); + } + } else { + animations[current].start(onComplete); + } + }, + + stop() { + if (current < animations.length) { + animations[current].stop(); + } + } + }; +} + +function delay(time) { + // Would be nice to make a specialized implementation + return timing(new AnimatedValue(0), { toValue: 0, delay: time, duration: 0 }); +} + +function stagger(time, animations) { + return parallel(animations.map(function (animation, i) { + return sequence([ + delay(time * i), + animation, + ]); + })); +} + +function event(argMapping, config) { + return function (...args) { + const traverse = function (recMapping, recEvt, key) { + if (typeof recEvt === 'number') { + invariant( + recMapping instanceof AnimatedValue, + 'Bad mapping of type ' + typeof recMapping + ' for key ' + key + + ', event value must map to AnimatedValue' + ); + recMapping.setValue(recEvt); + return; + } + invariant( + typeof recMapping === 'object', + 'Bad mapping of type ' + typeof recMapping + ' for key ' + key + ); + invariant( + typeof recEvt === 'object', + 'Bad event of type ' + typeof recEvt + ' for key ' + key + ); + for (const i in recMapping) { + traverse(recMapping[i], recEvt[i], i); + } + }; + argMapping.forEach((mapping, idx) => { + traverse(mapping, args[idx], 'arg' + idx); + }); + if (config && config.listener) { + config.listener.apply(null, args); + } + }; +} + +const AnimatedImplementation = { + Value: AnimatedValue, + ValueXY: AnimatedValueXY, + decay, + timing, + spring, + add, + multiply, + sequence, + parallel, + stagger, + event, + + __PropsOnlyForTests: AnimatedProps, + __Animated: Animated, + __Animation: Animation, + __AnimatedWithChildren: AnimatedWithChildren, + __AnimatedStyle: AnimatedStyle, +}; + +module.exports = AnimatedImplementation; diff --git a/jest/Animated/Easing.js b/jest/Animated/Easing.js new file mode 100755 index 00000000000000..03c40917597baa --- /dev/null +++ b/jest/Animated/Easing.js @@ -0,0 +1,267 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule Easing + * @flow + */ +'use strict'; + +let ease; + +/** + * The `Easing` module implements common easing functions. This module is used + * by [Animate.timing()](docs/animate.html#timing) to convey physically + * believable motion in animations. + * + * You can find a visualization of some common easing functions at + * http://easings.net/ + * + * ### Predefined animations + * + * The `Easing` module provides several predefined animations through the + * following methods: + * + * - [`back`](docs/easing.html#back) provides a simple animation where the + * object goes slightly back before moving forward + * - [`bounce`](docs/easing.html#bounce) provides a bouncing animation + * - [`ease`](docs/easing.html#ease) provides a simple inertial animation + * - [`elastic`](docs/easing.html#elastic) provides a simple spring interaction + * + * ### Standard functions + * + * Three standard easing functions are provided: + * + * - [`linear`](docs/easing.html#linear) + * - [`quad`](docs/easing.html#quad) + * - [`cubic`](docs/easing.html#cubic) + * + * The [`poly`](docs/easing.html#poly) function can be used to implement + * quartic, quintic, and other higher power functions. + * + * ### Additional functions + * + * Additional mathematical functions are provided by the following methods: + * + * - [`bezier`](docs/easing.html#bezier) provides a cubic bezier curve + * - [`circle`](docs/easing.html#circle) provides a circular function + * - [`sin`](docs/easing.html#sin) provides a sinusoidal function + * - [`exp`](docs/easing.html#exp) provides an exponential function + * + * The following helpers are used to modify other easing functions. + * + * - [`in`](docs/easing.html#in) runs an easing function forwards + * - [`inOut`](docs/easing.html#inout) makes any easing function symmetrical + * - [`out`](docs/easing.html#out) runs an easing function backwards + */ +class Easing { + /** + * A stepping function, returns 1 for any positive value of `n`. + */ + static step0(n) { + return n > 0 ? 1 : 0; + } + + /** + * A stepping function, returns 1 if `n` is greater than or equal to 1. + */ + static step1(n) { + return n >= 1 ? 1 : 0; + } + + /** + * A linear function, `f(t) = t`. Position correlates to elapsed time one to + * one. + * + * http://cubic-bezier.com/#0,0,1,1 + */ + static linear(t) { + return t; + } + + /** + * A simple inertial interaction, similar to an object slowly accelerating to + * speed. + * + * http://cubic-bezier.com/#.42,0,1,1 + */ + static ease(t: number): number { + if (!ease) { + ease = Easing.bezier(0.42, 0, 1, 1); + } + return ease(t); + } + + /** + * A quadratic function, `f(t) = t * t`. Position equals the square of elapsed + * time. + * + * http://easings.net/#easeInQuad + */ + static quad(t) { + return t * t; + } + + /** + * A cubic function, `f(t) = t * t * t`. Position equals the cube of elapsed + * time. + * + * http://easings.net/#easeInCubic + */ + static cubic(t) { + return t * t * t; + } + + /** + * A power function. Position is equal to the Nth power of elapsed time. + * + * n = 4: http://easings.net/#easeInQuart + * n = 5: http://easings.net/#easeInQuint + */ + static poly(n) { + return (t) => Math.pow(t, n); + } + + /** + * A sinusoidal function. + * + * http://easings.net/#easeInSine + */ + static sin(t) { + return 1 - Math.cos(t * Math.PI / 2); + } + + /** + * A circular function. + * + * http://easings.net/#easeInCirc + */ + static circle(t) { + return 1 - Math.sqrt(1 - t * t); + } + + /** + * An exponential function. + * + * http://easings.net/#easeInExpo + */ + static exp(t) { + return Math.pow(2, 10 * (t - 1)); + } + + /** + * A simple elastic interaction, similar to a spring oscillating back and + * forth. + * + * Default bounciness is 1, which overshoots a little bit once. 0 bounciness + * doesn't overshoot at all, and bounciness of N > 1 will overshoot about N + * times. + * + * http://easings.net/#easeInElastic + * + * Wolfram Plots: + * + * - http://tiny.cc/elastic_b_1 (bounciness = 1, default) + * - http://tiny.cc/elastic_b_3 (bounciness = 3) + */ + static elastic(bounciness: number = 1): (t: number) => number { + const p = bounciness * Math.PI; + return (t) => 1 - Math.pow(Math.cos(t * Math.PI / 2), 3) * Math.cos(t * p); + } + + /** + * Use with `Animated.parallel()` to create a simple effect where the object + * animates back slightly as the animation starts. + * + * Wolfram Plot: + * + * - http://tiny.cc/back_default (s = 1.70158, default) + */ + static back(s: number): (t: number) => number { + if (s === undefined) { + s = 1.70158; + } + return (t) => t * t * ((s + 1) * t - s); + } + + /** + * Provides a simple bouncing effect. + * + * http://easings.net/#easeInBounce + */ + static bounce(t: number): number { + if (t < 1 / 2.75) { + return 7.5625 * t * t; + } + + if (t < 2 / 2.75) { + t -= 1.5 / 2.75; + return 7.5625 * t * t + 0.75; + } + + if (t < 2.5 / 2.75) { + t -= 2.25 / 2.75; + return 7.5625 * t * t + 0.9375; + } + + t -= 2.625 / 2.75; + return 7.5625 * t * t + 0.984375; + } + + /** + * Provides a cubic bezier curve, equivalent to CSS Transitions' + * `transition-timing-function`. + * + * A useful tool to visualize cubic bezier curves can be found at + * http://cubic-bezier.com/ + */ + static bezier( + x1: number, + y1: number, + x2: number, + y2: number + ): (t: number) => number { + const _bezier = require('bezier'); + return _bezier(x1, y1, x2, y2); + } + + /** + * Runs an easing function forwards. + */ + static in( + easing: (t: number) => number, + ): (t: number) => number { + return easing; + } + + /** + * Runs an easing function backwards. + */ + static out( + easing: (t: number) => number, + ): (t: number) => number { + return (t) => 1 - easing(1 - t); + } + + /** + * Makes any easing function symmetrical. The easing function will run + * forwards for half of the duration, then backwards for the rest of the + * duration. + */ + static inOut( + easing: (t: number) => number, + ): (t: number) => number { + return (t) => { + if (t < 0.5) { + return easing(t * 2) / 2; + } + return 1 - easing((1 - t) * 2) / 2; + }; + } +} + +module.exports = Easing; diff --git a/jest/Animated/Interpolation.js b/jest/Animated/Interpolation.js new file mode 100755 index 00000000000000..aa38a490e8b9f1 --- /dev/null +++ b/jest/Animated/Interpolation.js @@ -0,0 +1,10 @@ +/** + * https://github.com/facebook/react-native/blob/master/Libraries/Animated/src/Interpolation.js + */ +class Interpolation { + static create(config) { + // TODO(lmr): + } +} + +module.exports = Interpolation; diff --git a/jest/Animated/SpringConfig.js b/jest/Animated/SpringConfig.js new file mode 100755 index 00000000000000..0488523a01f5a8 --- /dev/null +++ b/jest/Animated/SpringConfig.js @@ -0,0 +1,80 @@ +/** + * https://github.com/facebook/react-native/blob/master/Libraries/Animated/src/SpringConfig.js + */ + +function tensionFromOrigamiValue(oValue) { + return (oValue - 30) * 3.62 + 194; +} + +function frictionFromOrigamiValue(oValue) { + return (oValue - 8) * 3 + 25; +} + +function fromOrigamiTensionAndFriction(tension, friction) { + return { + tension: tensionFromOrigamiValue(tension), + friction: frictionFromOrigamiValue(friction) + }; +} + +function fromBouncinessAndSpeed(bounciness, speed) { + function normalize(value, startValue, endValue) { + return (value - startValue) / (endValue - startValue); + } + + function projectNormal(n, start, end) { + return start + (n * (end - start)); + } + + function linearInterpolation(t, start, end) { + return t * end + (1 - t) * start; + } + + function quadraticOutInterpolation(t, start, end) { + return linearInterpolation(2 * t - t * t, start, end); + } + + function b3Friction1(x) { + return (0.0007 * Math.pow(x, 3)) - + (0.031 * Math.pow(x, 2)) + 0.64 * x + 1.28; + } + + function b3Friction2(x) { + return (0.000044 * Math.pow(x, 3)) - + (0.006 * Math.pow(x, 2)) + 0.36 * x + 2; + } + + function b3Friction3(x) { + return (0.00000045 * Math.pow(x, 3)) - + (0.000332 * Math.pow(x, 2)) + 0.1078 * x + 5.84; + } + + function b3Nobounce(tension) { + if (tension <= 18) { + return b3Friction1(tension); + } else if (tension > 18 && tension <= 44) { + return b3Friction2(tension); + } + return b3Friction3(tension); + } + + let b = normalize(bounciness / 1.7, 0, 20); + b = projectNormal(b, 0, 0.8); + const s = normalize(speed / 1.7, 0, 20); + const bouncyTension = projectNormal(s, 0.5, 200); + const bouncyFriction = quadraticOutInterpolation( + b, + b3Nobounce(bouncyTension), + 0.01 + ); + + return { + tension: tensionFromOrigamiValue(bouncyTension), + friction: frictionFromOrigamiValue(bouncyFriction) + }; +} + +module.exports = { + fromOrigamiTensionAndFriction, + fromBouncinessAndSpeed, +}; diff --git a/jest/Animated/createAnimatedComponent.js b/jest/Animated/createAnimatedComponent.js new file mode 100755 index 00000000000000..6e9cba5ec3ceb8 --- /dev/null +++ b/jest/Animated/createAnimatedComponent.js @@ -0,0 +1,20 @@ +import React from 'react'; + +function createAnimatedComponent(Component) { + const refName = 'node'; + + class AnimatedComponent extends React.Component { + render() { + return ( + + ); + } + } + + return AnimatedComponent; +} + +module.exports = createAnimatedComponent; diff --git a/jest/Animated/index.js b/jest/Animated/index.js new file mode 100755 index 00000000000000..faa10a38ed6c05 --- /dev/null +++ b/jest/Animated/index.js @@ -0,0 +1,12 @@ +import createAnimatedComponent from './createAnimatedComponent'; +import AnimatedImplementation from './AnimatedImplementation'; + +const mockAnimatedComponent = require.requireActual('./mockAnimatedComponent'); + +module.exports = { + ...AnimatedImplementation, + createAnimatedComponent, + View: mockAnimatedComponent('Animated.View'), + Text: mockAnimatedComponent('Animated.Text'), + Image: mockAnimatedComponent('IAnimated.mage'), +}; diff --git a/jest/Animated/mockAnimatedComponent.js b/jest/Animated/mockAnimatedComponent.js new file mode 100644 index 00000000000000..ba9ca2ab3558d4 --- /dev/null +++ b/jest/Animated/mockAnimatedComponent.js @@ -0,0 +1,42 @@ +import React from 'react'; +var ViewStylePropTypes = require('ViewStylePropTypes'); + +module.exports = name => { + const React = require('react'); + + const AnimatedComponent = class extends React.Component { + render() { + if (global.__JSDOM_MOUNTABLE__) { + return React.createElement( + name.replace(/^(RCT|RK)/,''), + { children: this.props.children }, + ); + } + + return React.createElement( + name.replace(/^(RCT|RK)/,''), + this.props, + this.props.children, + ); + } + }; + // from Animated/AnimatedImplemenatation + AnimatedComponent.propTypes = { + style: function(props, propName, componentName) { + if (!propTypes) { + return; + } + + for (var key in ViewStylePropTypes) { + if (!propTypes[key] && props[key] !== undefined) { + console.warn( + 'You are setting the style `{ ' + key + ': ... }` as a prop. You ' + + 'should nest it in a style object. ' + + 'E.g. `{ style: { ' + key + ': ... } }`' + ); + } + } + }, + }; + return AnimatedComponent; +}; diff --git a/jest/InteractionManager.js b/jest/InteractionManager.js new file mode 100644 index 00000000000000..21c2371f21202e --- /dev/null +++ b/jest/InteractionManager.js @@ -0,0 +1,69 @@ +import keyMirror from 'keymirror'; +import invariant from 'invariant'; + +const { EventEmitter } = require('events'); + +const _emitter = new EventEmitter(); + +let _inc = 0; +let _deadline = -1; // eslint-disable-line no-unused-vars + +const InteractionManager = { + Events: keyMirror({ + interactionStart: true, + interactionComplete: true, + }), + + /** + * Schedule a function to run after all interactions have completed. + */ + runAfterInteractions(task) { + return new Promise(resolve => { + // TODO(lmr): + // _scheduleUpdate(); + // if (task) { + // _taskQueue.enqueue(task); + // } + // const name = task && task.name || '?'; + // _taskQueue.enqueue({ run: resolve, name: 'resolve ' + name }); + }); + }, + + /** + * Notify manager that an interaction has started. + */ + createInteractionHandle() { + // TODO(lmr): + // _scheduleUpdate(); + const handle = ++_inc; + // _addInteractionSet.add(handle); + return handle; + }, + + /** + * Notify manager that an interaction has completed. + */ + clearInteractionHandle(handle) { + invariant( + !!handle, + 'Must provide a handle to clear.' + ); + // TODO(lmr): + // _scheduleUpdate(); + // _addInteractionSet.delete(handle); + // _deleteInteractionSet.add(handle); + }, + + addListener: _emitter.addListener.bind(_emitter), + + /** + * A positive number will use setTimeout to schedule any tasks after the + * eventLoopRunningTime hits the deadline value, otherwise all tasks will be + * executed in one setImmediate batch (default). + */ + setDeadline(deadline) { + _deadline = deadline; + }, +}; + +module.exports = InteractionManager; diff --git a/jest/setup.js b/jest/setup.js index bf2084d3096910..e48b461d948d59 100644 --- a/jest/setup.js +++ b/jest/setup.js @@ -41,6 +41,7 @@ jest .mock('Modal', () => mockComponent('Modal')) .mock('View', () => mockComponent('View')) .mock('RefreshControl', () => require.requireMock('RefreshControlMock')) + .mock('Animated', () => require.requireActual('./Animated')) .mock('ScrollView', () => require.requireMock('ScrollViewMock')) .mock( 'ActivityIndicator',