From b7703105556ff21d0dddb44ef9907f233c0ce784 Mon Sep 17 00:00:00 2001 From: Spencer Ahrens Date: Tue, 7 Jul 2015 13:34:09 -0700 Subject: [PATCH] [ReactNative] Move Animated to Open Source Summary: Moves the files over and Exports Animated from 'react-native'. --- .../UIExplorer/AnimationExample/AnExApp.js | 319 ++++ .../UIExplorer/AnimationExample/AnExBobble.js | 162 +++ .../AnimationExample/AnExChained.js | 114 ++ .../UIExplorer/AnimationExample/AnExScroll.js | 115 ++ .../UIExplorer/AnimationExample/AnExSet.js | 150 ++ .../UIExplorer/AnimationExample/AnExSlides.md | 107 ++ .../UIExplorer/AnimationExample/AnExTilt.js | 139 ++ Examples/UIExplorer/UIExplorerList.js | 1 + Libraries/Animation/Animated/Animated.js | 1295 +++++++++++++++++ Libraries/Animation/Animated/Easing.js | 148 ++ Libraries/Animation/Animated/Interpolation.js | 258 ++++ .../Animated/__tests__/Animated-test.js | 449 ++++++ .../Animated/__tests__/Easing-test.js | 119 ++ .../Animated/__tests__/Interpolation-test.js | 256 ++++ Libraries/Animation/Animated/package.json | 13 + Libraries/Animation/bezier.js | 80 + Libraries/react-native/react-native.js | 1 + 17 files changed, 3726 insertions(+) create mode 100644 Examples/UIExplorer/AnimationExample/AnExApp.js create mode 100644 Examples/UIExplorer/AnimationExample/AnExBobble.js create mode 100644 Examples/UIExplorer/AnimationExample/AnExChained.js create mode 100644 Examples/UIExplorer/AnimationExample/AnExScroll.js create mode 100644 Examples/UIExplorer/AnimationExample/AnExSet.js create mode 100644 Examples/UIExplorer/AnimationExample/AnExSlides.md create mode 100644 Examples/UIExplorer/AnimationExample/AnExTilt.js create mode 100644 Libraries/Animation/Animated/Animated.js create mode 100644 Libraries/Animation/Animated/Easing.js create mode 100644 Libraries/Animation/Animated/Interpolation.js create mode 100644 Libraries/Animation/Animated/__tests__/Animated-test.js create mode 100644 Libraries/Animation/Animated/__tests__/Easing-test.js create mode 100644 Libraries/Animation/Animated/__tests__/Interpolation-test.js create mode 100644 Libraries/Animation/Animated/package.json create mode 100644 Libraries/Animation/bezier.js diff --git a/Examples/UIExplorer/AnimationExample/AnExApp.js b/Examples/UIExplorer/AnimationExample/AnExApp.js new file mode 100644 index 000000000..9ea48f419 --- /dev/null +++ b/Examples/UIExplorer/AnimationExample/AnExApp.js @@ -0,0 +1,319 @@ +/** + * The examples provided by Facebook are for non-commercial testing and + * evaluation purposes only. + * + * Facebook reserves all rights not expressly granted. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL + * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * @providesModule AnExApp + * @flow + */ +'use strict'; + +var React = require('react-native'); +var { + Animated, + LayoutAnimation, + PanResponder, + StyleSheet, + View, +} = React; + +var AnExSet = require('AnExSet'); + +var CIRCLE_SIZE = 80; +var CIRCLE_MARGIN = 18; +var NUM_CIRCLES = 30; + +class Circle extends React.Component { + _onLongPress: () => void; + _toggleIsActive: () => void; + constructor(props: Object): void { + super(); + this._onLongPress = this._onLongPress.bind(this); + this._toggleIsActive = this._toggleIsActive.bind(this); + this.state = {isActive: false}; + this.state.pan = new Animated.ValueXY(); // Vectors reduce boilerplate. (step1: uncomment) + this.state.pop = new Animated.Value(0); // Initial value. (step2a: uncomment) + } + + _onLongPress(): void { + var config = {tension: 40, friction: 3}; + this.state.pan.addListener((value) => { // Async listener for state changes (step1: uncomment) + this.props.onMove && this.props.onMove(value); + }); + Animated.spring(this.state.pop, { + toValue: 1, // Pop to larger size. (step2b: uncomment) + ...config, // Reuse config for convenient consistency (step2b: uncomment) + }).start(); + this.setState({panResponder: PanResponder.create({ + onPanResponderMove: Animated.event([ + null, // native event - ignore (step1: uncomment) + {dx: this.state.pan.x, dy: this.state.pan.y}, // links pan to gestureState (step1: uncomment) + ]), + onPanResponderRelease: (e, gestureState) => { + LayoutAnimation.easeInEaseOut(); // @flowfixme animates layout update as one batch (step3: uncomment) + Animated.spring(this.state.pop, { + toValue: 0, // Pop back to 0 (step2c: uncomment) + ...config, + }).start(); + this.setState({panResponder: undefined}); + this.props.onMove && this.props.onMove({ + x: gestureState.dx + this.props.restLayout.x, + y: gestureState.dy + this.props.restLayout.y, + }); + this.props.onDeactivate(); + }, + })}, () => { + this.props.onActivate(); + }); + } + + render(): ReactElement { + if (this.state.panResponder) { + var handlers = this.state.panResponder.panHandlers; + var dragStyle = { // Used to position while dragging + position: 'absolute', // Hoist out of layout (step1: uncomment) + ...this.state.pan.getLayout(), // Convenience converter (step1: uncomment) + }; + } else { + handlers = { + onStartShouldSetResponder: () => !this.state.isActive, + onResponderGrant: () => { + this.state.pan.setValue({x: 0, y: 0}); // reset (step1: uncomment) + this.state.pan.setOffset(this.props.restLayout); // offset from onLayout (step1: uncomment) + this.longTimer = setTimeout(this._onLongPress, 300); + }, + onResponderRelease: () => { + if (!this.state.panResponder) { + clearTimeout(this.longTimer); + this._toggleIsActive(); + } + } + }; + } + var animatedStyle: Object = { + shadowOpacity: this.state.pop, // no need for interpolation (step2d: uncomment) + transform: [ + {scale: this.state.pop.interpolate({ + inputRange: [0, 1], + outputRange: [1, 1.3] // scale up from 1 to 1.3 (step2d: uncomment) + })}, + ], + }; + var openVal = this.props.openVal; + if (this.props.dummy) { + animatedStyle.opacity = 0; + } else if (this.state.isActive) { + var innerOpenStyle = [styles.open, { // (step4: uncomment) + left: openVal.interpolate({inputRange: [0, 1], outputRange: [this.props.restLayout.x, 0]}), + top: openVal.interpolate({inputRange: [0, 1], outputRange: [this.props.restLayout.y, 0]}), + width: openVal.interpolate({inputRange: [0, 1], outputRange: [CIRCLE_SIZE, this.props.containerLayout.width]}), + height: openVal.interpolate({inputRange: [0, 1], outputRange: [CIRCLE_SIZE, this.props.containerLayout.height]}), + margin: openVal.interpolate({inputRange: [0, 1], outputRange: [CIRCLE_MARGIN, 0]}), + borderRadius: openVal.interpolate({inputRange: [-0.15, 0, 0.5, 1], outputRange: [0, CIRCLE_SIZE / 2, CIRCLE_SIZE * 1.3, 0]}), + }]; + } + return ( + + + + + + ); + } + _toggleIsActive(velocity) { + var config = {tension: 30, friction: 7}; + if (this.state.isActive) { + Animated.spring(this.props.openVal, {toValue: 0, ...config}).start(() => { // (step4: uncomment) + this.setState({isActive: false}, this.props.onDeactivate); + }); // (step4: uncomment) + } else { + this.props.onActivate(); + this.setState({isActive: true, panResponder: undefined}, () => { + // this.props.openVal.setValue(1); // (step4: comment) + Animated.spring(this.props.openVal, {toValue: 1, ...config}).start(); // (step4: uncomment) + }); + } + } +} + +class AnExApp extends React.Component { + _onMove: (position: Point) => void; + constructor(props: any): void { + super(props); + var keys = []; + for (var idx = 0; idx < NUM_CIRCLES; idx++) { + keys.push('E' + idx); + } + this.state = { + keys, + restLayouts: [], + openVal: new Animated.Value(0), + }; + this._onMove = this._onMove.bind(this); + } + + render(): ReactElement { + var circles = this.state.keys.map((key, idx) => { + if (key === this.state.activeKey) { + return ; + } else { + if (!this.state.restLayouts[idx]) { + var onLayout = function(index, e) { + var layout = e.nativeEvent.layout; + this.setState((state) => { + state.restLayouts[index] = layout; + return state; + }); + }.bind(this, idx); + } + return ( + + ); + } + }); + if (this.state.activeKey) { + circles.push( + + ); + circles.push( + { this.setState({activeKey: undefined}); }} + /> + ); + } + return ( + + this.setState({layout: e.nativeEvent.layout})}> + {circles} + + + ); + } + + _onMove(position: Point): void { + var newKeys = moveToClosest(this.state, position); + if (newKeys !== this.state.keys) { + LayoutAnimation.easeInEaseOut(); // animates layout update as one batch (step3: uncomment) + this.setState({keys: newKeys}); + } + } +} + +type Point = {x: number, y: number}; +function distance(p1: Point, p2: Point): number { + var dx = p1.x - p2.x; + var dy = p1.y - p2.y; + return dx * dx + dy * dy; +} + +function moveToClosest({activeKey, keys, restLayouts}, position) { + var activeIdx = -1; + var closestIdx = activeIdx; + var minDist = Infinity; + var newKeys = []; + keys.forEach((key, idx) => { + var dist = distance(position, restLayouts[idx]); + if (key === activeKey) { + idx = activeIdx; + } else { + newKeys.push(key); + } + if (dist < minDist) { + minDist = dist; + closestIdx = idx; + } + }); + if (closestIdx === activeIdx) { + return keys; // nothing changed + } else { + newKeys.splice(closestIdx, 0, activeKey); + return newKeys; + } +} + +AnExApp.title = 'Animated - Gratuitous App'; +AnExApp.description = 'Bunch of Animations - tap a circle to ' + + 'open a view with more animations, or longPress and drag to reorder circles.'; + +var styles = StyleSheet.create({ + container: { + flex: 1, + paddingTop: 64, // push content below nav bar + }, + grid: { + flex: 1, + justifyContent: 'center', + flexDirection: 'row', + flexWrap: 'wrap', + backgroundColor: 'transparent', + }, + circle: { + width: CIRCLE_SIZE, + height: CIRCLE_SIZE, + borderRadius: CIRCLE_SIZE / 2, + borderWidth: 1, + borderColor: 'black', + margin: CIRCLE_MARGIN, + overflow: 'hidden', + }, + dragView: { + shadowRadius: 10, + shadowColor: 'rgba(0,0,0,0.7)', + shadowOffset: {height: 8}, + alignSelf: 'flex-start', + backgroundColor: 'transparent', + }, + open: { + position: 'absolute', + left: 0, + top: 0, + right: 0, + bottom: 0, + width: undefined, // unset value from styles.circle + height: undefined, // unset value from styles.circle + borderRadius: 0, // unset value from styles.circle + }, + darkening: { + backgroundColor: 'black', + position: 'absolute', + left: 0, + top: 0, + right: 0, + bottom: 0, + }, +}); + +module.exports = AnExApp; diff --git a/Examples/UIExplorer/AnimationExample/AnExBobble.js b/Examples/UIExplorer/AnimationExample/AnExBobble.js new file mode 100644 index 000000000..bebad819d --- /dev/null +++ b/Examples/UIExplorer/AnimationExample/AnExBobble.js @@ -0,0 +1,162 @@ +/** + * The examples provided by Facebook are for non-commercial testing and + * evaluation purposes only. + * + * Facebook reserves all rights not expressly granted. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL + * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * @providesModule AnExBobble + * @flow + */ +'use strict'; + +var React = require('react-native'); +var { + Animated, + Image, + PanResponder, + StyleSheet, + View, +} = React; + +var NUM_BOBBLES = 5; +var RAD_EACH = Math.PI / 2 / (NUM_BOBBLES - 2); +var RADIUS = 160; +var BOBBLE_SPOTS = [...Array(NUM_BOBBLES)].map((_, i) => { // static positions + return i === 0 ? {x: 0, y: 0} : { // first bobble is the selector + x: -Math.cos(RAD_EACH * (i - 1)) * RADIUS, + y: -Math.sin(RAD_EACH * (i - 1)) * RADIUS, + }; +}); + +class AnExBobble extends React.Component { + constructor(props: Object) { + super(props); + this.state = {}; + this.state.bobbles = BOBBLE_SPOTS.map((_, i) => { + return new Animated.ValueXY(); + }); + this.state.selectedBobble = null; + var bobblePanListener = (e, gestureState) => { // async events => change selection + var newSelected = computeNewSelected(gestureState); + if (this.state.selectedBobble !== newSelected) { + if (this.state.selectedBobble !== null) { + var restSpot = BOBBLE_SPOTS[this.state.selectedBobble]; + Animated.spring(this.state.bobbles[this.state.selectedBobble], { + toValue: restSpot, // return previously selected bobble to rest position + }).start(); + } + if (newSelected !== null && newSelected !== 0) { + Animated.spring(this.state.bobbles[newSelected], { + toValue: this.state.bobbles[0], // newly selected should track the selector + }).start(); + } + this.state.selectedBobble = newSelected; + } + }; + var releaseBobble = () => { + this.state.bobbles.forEach((bobble, i) => { + Animated.spring(bobble, { + toValue: {x: 0, y: 0} // all bobbles return to zero + }).start(); + }); + }; + this.state.bobbleResponder = PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onPanResponderGrant: () => { + BOBBLE_SPOTS.forEach((spot, idx) => { + Animated.spring(this.state.bobbles[idx], { + toValue: spot, // spring each bobble to its spot + friction: 3, // less friction => bouncier + }).start(); + }); + }, + onPanResponderMove: Animated.event( + [ null, {dx: this.state.bobbles[0].x, dy: this.state.bobbles[0].y} ], + {listener: bobblePanListener} // async state changes with arbitrary logic + ), + onPanResponderRelease: releaseBobble, + onPanResponderTerminate: releaseBobble, + }); + } + + render(): ReactElement { + return ( + + {this.state.bobbles.map((_, i) => { + var j = this.state.bobbles.length - i - 1; // reverse so lead on top + var handlers = j > 0 ? {} : this.state.bobbleResponder.panHandlers; + return ( + + ); + })} + + ); + } +} + +var styles = StyleSheet.create({ + circle: { + position: 'absolute', + height: 60, + width: 60, + borderRadius: 30, + borderWidth: 0.5, + }, + bobbleContainer: { + top: -68, + paddingRight: 66, + flexDirection: 'row', + flex: 1, + justifyContent: 'flex-end', + backgroundColor: 'transparent', + }, +}); + +function computeNewSelected( + gestureState: Object, +): ?number { + var {dx, dy} = gestureState; + var minDist = Infinity; + var newSelected = null; + var pointRadius = Math.sqrt(dx * dx + dy * dy); + if (Math.abs(RADIUS - pointRadius) < 80) { + BOBBLE_SPOTS.forEach((spot, idx) => { + var delta = {x: spot.x - dx, y: spot.y - dy}; + var dist = delta.x * delta.x + delta.y * delta.y; + if (dist < minDist) { + minDist = dist; + newSelected = idx; + } + }); + } + return newSelected; +} + +function randColor(): string { + var colors = [0,1,2].map(() => Math.floor(Math.random() * 150 + 100)); + return 'rgb(' + colors.join(',') + ')'; +} + +var BOBBLE_IMGS = [ + 'https://scontent-sea1-1.xx.fbcdn.net/hphotos-xpf1/t39.1997-6/10173489_272703316237267_1025826781_n.png', + 'https://scontent-sea1-1.xx.fbcdn.net/hphotos-xaf1/l/t39.1997-6/p240x240/851578_631487400212668_2087073502_n.png', + 'https://scontent-sea1-1.xx.fbcdn.net/hphotos-xaf1/t39.1997-6/p240x240/851583_654446917903722_178118452_n.png', + 'https://scontent-sea1-1.xx.fbcdn.net/hphotos-xaf1/t39.1997-6/p240x240/851565_641023175913294_875343096_n.png', + 'https://scontent-sea1-1.xx.fbcdn.net/hphotos-xaf1/t39.1997-6/851562_575284782557566_1188781517_n.png', +]; + +module.exports = AnExBobble; diff --git a/Examples/UIExplorer/AnimationExample/AnExChained.js b/Examples/UIExplorer/AnimationExample/AnExChained.js new file mode 100644 index 000000000..cf4083cff --- /dev/null +++ b/Examples/UIExplorer/AnimationExample/AnExChained.js @@ -0,0 +1,114 @@ +/** + * The examples provided by Facebook are for non-commercial testing and + * evaluation purposes only. + * + * Facebook reserves all rights not expressly granted. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL + * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * @providesModule AnExChained + * @flow + */ +'use strict'; + +var React = require('react-native'); +var { + Animated, + Image, + PanResponder, + StyleSheet, + View, +} = React; + +class AnExChained extends React.Component { + constructor(props: Object) { + super(props); + this.state = { + stickers: [new Animated.ValueXY()], // 1 leader + }; + var stickerConfig = {tension: 2, friction: 3}; // soft spring + for (var i = 0; i < 4; i++) { // 4 followers + var sticker = new Animated.ValueXY(); + Animated.spring(sticker, { + ...stickerConfig, + toValue: this.state.stickers[i], // Animated toValue's are tracked + }).start(); + this.state.stickers.push(sticker); // push on the followers + } + var releaseChain = (e, gestureState) => { + this.state.stickers[0].flattenOffset(); // merges offset into value and resets + Animated.sequence([ // spring to start after decay finishes + Animated.decay(this.state.stickers[0], { // coast to a stop + velocity: {x: gestureState.vx, y: gestureState.vy}, + deceleration: 0.997, + }), + Animated.spring(this.state.stickers[0], { + toValue: {x: 0, y: 0} // return to start + }), + ]).start(); + }; + this.state.chainResponder = PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onPanResponderGrant: () => { + this.state.stickers[0].stopAnimation((value) => { + this.state.stickers[0].setOffset(value); // start where sticker animated to + this.state.stickers[0].setValue({x: 0, y: 0}); // avoid flicker before next event + }); + }, + onPanResponderMove: Animated.event( + [null, {dx: this.state.stickers[0].x, dy: this.state.stickers[0].y}] // map gesture to leader + ), + onPanResponderRelease: releaseChain, + onPanResponderTerminate: releaseChain, + }); + } + + render() { + return ( + + {this.state.stickers.map((_, i) => { + var j = this.state.stickers.length - i - 1; // reverse so leader is on top + var handlers = (j === 0) ? this.state.chainResponder.panHandlers : {}; + return ( + + ); + })} + + ); + } +} + +var styles = StyleSheet.create({ + chained: { + alignSelf: 'flex-end', + top: -160, + right: 126 + }, + sticker: { + position: 'absolute', + height: 120, + width: 120, + backgroundColor: 'transparent', + }, +}); + +var CHAIN_IMGS = [ + 'https://scontent-sea1-1.xx.fbcdn.net/hphotos-xpf1/t39.1997-6/p160x160/10574705_1529175770666007_724328156_n.png', + 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-xfa1/t39.1997-6/p160x160/851575_392309884199657_1917957497_n.png', + 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-xfa1/t39.1997-6/p160x160/851567_555288911225630_1628791128_n.png', + 'https://scontent-sea1-1.xx.fbcdn.net/hphotos-xfa1/t39.1997-6/p160x160/851583_531111513625557_903469595_n.png', + 'https://scontent-sea1-1.xx.fbcdn.net/hphotos-xpa1/t39.1997-6/p160x160/851577_510515972354399_2147096990_n.png', +]; + +module.exports = AnExChained; diff --git a/Examples/UIExplorer/AnimationExample/AnExScroll.js b/Examples/UIExplorer/AnimationExample/AnExScroll.js new file mode 100644 index 000000000..f96acb380 --- /dev/null +++ b/Examples/UIExplorer/AnimationExample/AnExScroll.js @@ -0,0 +1,115 @@ +/** + * The examples provided by Facebook are for non-commercial testing and + * evaluation purposes only. + * + * Facebook reserves all rights not expressly granted. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL + * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * @providesModule AnExScroll + */ +'use strict'; + +var React = require('react-native'); +var { + Animated, + Image, + ScrollView, + StyleSheet, + Text, + View, +} = React; + +class AnExScroll extends React.Component { + constructor(props) { + super(props); + this.state = { + scrollX: new Animated.Value(0), + }; + } + + render() { + var width = this.props.panelWidth; + return ( + + + + + + {'I\'ll find something to put here.'} + + + + {'And here.'} + + + {'But not here.'} + + + + + ); + } +} + +var styles = StyleSheet.create({ + container: { + backgroundColor: 'transparent', + flex: 1, + }, + text: { + padding: 4, + paddingBottom: 10, + fontWeight: 'bold', + fontSize: 18, + backgroundColor: 'transparent', + }, + bunny: { + backgroundColor: 'transparent', + position: 'absolute', + height: 160, + width: 160, + }, + page: { + alignItems: 'center', + justifyContent: 'flex-end', + }, +}); + +var HAWK_PIC = {uri: 'https://scontent-sea1-1.xx.fbcdn.net/hphotos-xfa1/t39.1997-6/10734304_1562225620659674_837511701_n.png'}; +var BUNNY_PIC = {uri: 'https://scontent-sea1-1.xx.fbcdn.net/hphotos-xaf1/t39.1997-6/851564_531111380292237_1898871086_n.png'}; + +module.exports = AnExScroll; diff --git a/Examples/UIExplorer/AnimationExample/AnExSet.js b/Examples/UIExplorer/AnimationExample/AnExSet.js new file mode 100644 index 000000000..f25301f77 --- /dev/null +++ b/Examples/UIExplorer/AnimationExample/AnExSet.js @@ -0,0 +1,150 @@ +/** + * The examples provided by Facebook are for non-commercial testing and + * evaluation purposes only. + * + * Facebook reserves all rights not expressly granted. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL + * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * @providesModule AnExSet + * @flow + */ +'use strict'; + +var React = require('react-native'); +var { + Animated, + PanResponder, + StyleSheet, + Text, + View, +} = React; + +var AnExBobble = require('./AnExBobble'); +var AnExChained = require('./AnExChained'); +var AnExScroll = require('./AnExScroll'); +var AnExTilt = require('./AnExTilt'); + +class AnExSet extends React.Component { + constructor(props: Object) { + super(props); + function randColor() { + var colors = [0,1,2].map(() => Math.floor(Math.random() * 150 + 100)); + return 'rgb(' + colors.join(',') + ')'; + } + this.state = { + closeColor: randColor(), + openColor: randColor(), + }; + } + render(): ReactElement { + var backgroundColor = this.props.openVal ? + this.props.openVal.interpolate({ + inputRange: [0, 1], + outputRange: [ + this.state.closeColor, // interpolates color strings + this.state.openColor + ], + }) : + this.state.closeColor; + var panelWidth = this.props.containerLayout && this.props.containerLayout.width || 320; + return ( + + + + {this.props.id} + + + {this.props.isActive && + + + + July 2nd + + + + + + + + } + + ); + } + + componentWillMount() { + this.state.dismissY = new Animated.Value(0); + this.state.dismissResponder = PanResponder.create({ + onStartShouldSetPanResponder: () => this.props.isActive, + onPanResponderGrant: () => { + Animated.spring(this.props.openVal, { // Animated value passed in. + toValue: this.state.dismissY.interpolate({ // Track dismiss gesture + inputRange: [0, 300], // and interpolate pixel distance + outputRange: [1, 0], // to a fraction. + }) + }).start(); + }, + onPanResponderMove: Animated.event( + [null, {dy: this.state.dismissY}] // track pan gesture + ), + onPanResponderRelease: (e, gestureState) => { + if (gestureState.dy > 100) { + this.props.onDismiss(gestureState.vy); // delegates dismiss action to parent + } else { + Animated.spring(this.props.openVal, { + toValue: 1, // animate back open if released early + }).start(); + } + }, + }); + } +} + +var styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + alignItems: 'center', + paddingTop: 18, + height: 90, + }, + stream: { + flex: 1, + backgroundColor: 'rgb(230, 230, 230)', + }, + card: { + margin: 8, + padding: 8, + borderRadius: 6, + backgroundColor: 'white', + shadowRadius: 2, + shadowColor: 'black', + shadowOpacity: 0.2, + shadowOffset: {height: 0.5}, + }, + text: { + padding: 4, + paddingBottom: 10, + fontWeight: 'bold', + fontSize: 18, + backgroundColor: 'transparent', + }, + headerText: { + fontSize: 25, + color: 'white', + shadowRadius: 3, + shadowColor: 'black', + shadowOpacity: 1, + shadowOffset: {height: 1}, + }, +}); + +module.exports = AnExSet; diff --git a/Examples/UIExplorer/AnimationExample/AnExSlides.md b/Examples/UIExplorer/AnimationExample/AnExSlides.md new file mode 100644 index 000000000..bdc0bf1e0 --- /dev/null +++ b/Examples/UIExplorer/AnimationExample/AnExSlides.md @@ -0,0 +1,107 @@ +

+# React Native: Animated + +ReactEurope 2015, Paris - Spencer Ahrens - Facebook + +

+ +## Fluid Interactions + +- People expect smooth, delightful experiences +- Complex interactions are hard +- Common patterns can be optimized + +

+ + +## Declarative Interactions + +- Wire up inputs (events) to outputs (props) + transforms (springs, easing, etc.) +- Arbitrary code can define/update this config +- Config can be serialized -> native/main thread +- No refs or lifecycle to worry about + +

+ + +## var { Animated } = require('react-native'); + +- New library soon to be released for React Native +- 100% JS implementation -> X-Platform +- Per-platform native optimizations planned +- This talk -> usage examples, not implementation + +

+ + +## Gratuitous Animation Demo App + +- Layout uses `flexWrap: 'wrap'` +- longPress -> drag to reorder +- Tap to open example sets + +

+ +## Gratuitous Animation Codez + +- Step 1: 2D tracking pan gesture +- Step 2: Simple pop-out spring on select +- Step 3: Animate grid reordering with `LayoutAnimation` +- Step 4: Opening animation + +

+ +## Animation Example Set + +- `Animated.Value` `this.props.open` passed in from parent +- `interpolate` works with string "shapes," e.g. `'rgb(0, 0, 255)'`, `'45deg'` +- Examples easily composed as separate components +- Dismissing tracks interpolated gesture +- Custom release logic + +

+ + +## Tilting Photo + +- Pan -> translateX * 2, rotate, opacity (via tracking) +- Gesture release triggers separate animations +- `addListener` for async, arbitrary logic on animation progress +- `interpolate` easily creates parallax and other effects + +

+ +## Bobbles + +- Static positions defined +- Listens to events to maybe change selection + - Springs previous selection back + - New selection tracks selector +- `getTranslateTransform` adds convenience + +

+ +## Chained + +- Classic "Chat Heads" animation +- Each sticker tracks the one before it with a soft spring +- `decay` maintains gesture velocity, followed by `spring` to home +- `stopAnimation` provides the last value for `setOffset` + +

+ +## Scrolling + +- `Animated.event` can track all sorts of stuff +- Multi-part ranges and extrapolation options +- Transforms decompose into ordered components + +

+ +# React Native: Animated + +- Landing soon in master (days) +- GitHub: @vjeux, @sahrens +- Questions? + +
diff --git a/Examples/UIExplorer/AnimationExample/AnExTilt.js b/Examples/UIExplorer/AnimationExample/AnExTilt.js new file mode 100644 index 000000000..3cea77917 --- /dev/null +++ b/Examples/UIExplorer/AnimationExample/AnExTilt.js @@ -0,0 +1,139 @@ +/** + * The examples provided by Facebook are for non-commercial testing and + * evaluation purposes only. + * + * Facebook reserves all rights not expressly granted. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL + * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * @providesModule AnExTilt + * @flow + */ +'use strict'; + +var React = require('react-native'); +var { + Animated, + Image, + PanResponder, + StyleSheet, + View, +} = React; + +class AnExTilt extends React.Component { + constructor(props: Object) { + super(props); + this.state = { + panX: new Animated.Value(0), + opacity: new Animated.Value(1), + burns: new Animated.Value(1.15), + }; + this.state.tiltPanResponder = PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onPanResponderGrant: () => { + Animated.timing(this.state.opacity, { + toValue: this.state.panX.interpolate({ + inputRange: [-300, 0, 300], // pan is in pixels + outputRange: [0, 1, 0], // goes to zero at both edges + }), + duration: 0, // direct tracking + }).start(); + }, + onPanResponderMove: Animated.event( + [null, {dx: this.state.panX}] // panX is linked to the gesture + ), + onPanResponderRelease: (e, gestureState) => { + var toValue = 0; + if (gestureState.dx > 100) { + toValue = 500; + } else if (gestureState.dx < -100) { + toValue = -500; + } + Animated.spring(this.state.panX, { + toValue, // animate back to center or off screen + velocity: gestureState.vx, // maintain gesture velocity + tension: 10, + friction: 3, + }).start(); + this.state.panX.removeAllListeners(); + var id = this.state.panX.addListener(({value}) => { // listen until offscreen + if (Math.abs(value) > 400) { + this.state.panX.removeListener(id); // offscreen, so stop listening + Animated.timing(this.state.opacity, { + toValue: 1, // Fade back in. This unlinks it from tracking this.state.panX + }).start(); + this.state.panX.setValue(0); // Note: stops the spring animation + toValue !== 0 && this._startBurnsZoom(); + } + }); + }, + }); + } + + _startBurnsZoom() { + this.state.burns.setValue(1); // reset to beginning + Animated.decay(this.state.burns, { + velocity: 1, // sublte zoom + deceleration: 0.9999, // slow decay + }).start(); + } + + componentWillMount() { + this._startBurnsZoom(); + } + + render(): ReactElement { + return ( + + + + ); + } +} + +var styles = StyleSheet.create({ + tilt: { + overflow: 'hidden', + height: 200, + marginBottom: 4, + backgroundColor: 'rgb(130, 130, 255)', + borderColor: 'rgba(0, 0, 0, 0.2)', + borderWidth: 1, + borderRadius: 20, + }, +}); + +var NATURE_IMAGE = {uri: 'http://www.deshow.net/d/file/travel/2009-04/scenic-beauty-of-nature-photography-2-504-4.jpg'}; + +module.exports = AnExTilt; diff --git a/Examples/UIExplorer/UIExplorerList.js b/Examples/UIExplorer/UIExplorerList.js index 8a7782484..47707f4e4 100644 --- a/Examples/UIExplorer/UIExplorerList.js +++ b/Examples/UIExplorer/UIExplorerList.js @@ -50,6 +50,7 @@ var COMMON_COMPONENTS = [ ]; var COMMON_APIS = [ + require('./AnimationExample/AnExApp'), require('./GeolocationExample'), require('./LayoutExample'), require('./PanResponderExample'), diff --git a/Libraries/Animation/Animated/Animated.js b/Libraries/Animation/Animated/Animated.js new file mode 100644 index 000000000..fd0809825 --- /dev/null +++ b/Libraries/Animation/Animated/Animated.js @@ -0,0 +1,1295 @@ +/** + * 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 Animated + * @flow + */ +'use strict'; + +var Easing = require('Easing'); +var Image = require('Image'); +var InteractionManager = require('InteractionManager'); +var Interpolation = require('Interpolation'); +var React = require('React'); +var Set = require('Set'); +var Text = require('Text'); +var View = require('View'); +var invariant = require('invariant'); + +var flattenStyle = require('flattenStyle'); +var rebound = require('rebound'); + +import type InterpolationConfigType from 'Interpolation'; + +type EndResult = {finished: bool}; +type EndCallback = (result: EndResult) => void; + +// Note(vjeux): this would be better as an interface but flow doesn't +// support them yet +class Animated { + attach(): void {} + detach(): void {} + __getValue(): any {} + getAnimatedValue(): any { return this.__getValue(); } + addChild(child: Animated) {} + removeChild(child: Animated) {} + getChildren(): Array { return []; } +} + +// Important note: start() and stop() will only be called at most once. +// Once an animation has been stopped or finished its course, it will +// not be reused. +class Animation { + __active: bool; + __onEnd: ?EndCallback; + start( + fromValue: number, + onUpdate: (value: number) => void, + onEnd: ?EndCallback, + previousAnimation: ?Animation, + ): void {} + stop(): void {} + // Helper function for subclasses to make sure onEnd is only called once. + __debouncedOnEnd(result: EndResult) { + var onEnd = this.__onEnd; + this.__onEnd = null; + onEnd && onEnd(result); + } +} + +class AnimatedWithChildren extends Animated { + _children: Array; + + constructor() { + super(); + this._children = []; + } + + addChild(child: Animated): void { + if (this._children.length === 0) { + this.attach(); + } + this._children.push(child); + } + + removeChild(child: Animated): void { + var 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(): Array { + 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: AnimatedValue): void { + var 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()); +} + +type TimingAnimationConfig = { + toValue: number; + easing?: (value: number) => number; + duration?: number; + delay?: number; +}; + +var easeInOut = Easing.inOut(Easing.ease); + +class TimingAnimation extends Animation { + _startTime: number; + _fromValue: number; + _toValue: number; + _duration: number; + _delay: number; + _easing: (value: number) => number; + _onUpdate: (value: number) => void; + _animationFrame: any; + _timeout: any; + + constructor( + config: TimingAnimationConfig, + ) { + super(); + this._toValue = config.toValue; + this._easing = config.easing || easeInOut; + this._duration = config.duration !== undefined ? config.duration : 500; + this._delay = config.delay || 0; + } + + start( + fromValue: number, + onUpdate: (value: number) => void, + onEnd: ?EndCallback, + ): void { + this.__active = true; + this._fromValue = fromValue; + this._onUpdate = onUpdate; + this.__onEnd = onEnd; + + var start = () => { + if (this._duration === 0) { + this._onUpdate(this._toValue); + } else { + this._startTime = Date.now(); + this._animationFrame = window.requestAnimationFrame(this.onUpdate.bind(this)); + } + }; + if (this._delay) { + this._timeout = setTimeout(start, this._delay); + } else { + start(); + } + } + + onUpdate(): void { + var 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 = window.requestAnimationFrame(this.onUpdate.bind(this)); + } + } + + stop(): void { + this.__active = false; + clearTimeout(this._timeout); + window.cancelAnimationFrame(this._animationFrame); + this.__debouncedOnEnd({finished: false}); + } +} + +type DecayAnimationConfig = { + velocity: number | {x: number, y: number}; + deceleration?: number; +}; + +type DecayAnimationConfigSingle = { + velocity: number; + deceleration?: number; +}; + +class DecayAnimation extends Animation { + _startTime: number; + _lastValue: number; + _fromValue: number; + _deceleration: number; + _velocity: number; + _onUpdate: (value: number) => void; + _animationFrame: any; + + constructor( + config: DecayAnimationConfigSingle, + ) { + super(); + this._deceleration = config.deceleration || 0.998; + this._velocity = config.velocity; + } + + start( + fromValue: number, + onUpdate: (value: number) => void, + onEnd: ?EndCallback, + ): void { + this.__active = true; + this._lastValue = fromValue; + this._fromValue = fromValue; + this._onUpdate = onUpdate; + this.__onEnd = onEnd; + this._startTime = Date.now(); + this._animationFrame = window.requestAnimationFrame(this.onUpdate.bind(this)); + } + + onUpdate(): void { + var now = Date.now(); + + var 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 = window.requestAnimationFrame(this.onUpdate.bind(this)); + } + } + + stop(): void { + this.__active = false; + window.cancelAnimationFrame(this._animationFrame); + this.__debouncedOnEnd({finished: false}); + } +} + +type SpringAnimationConfig = { + toValue: number | AnimatedValue | {x: number, y: number} | AnimatedValueXY; + overshootClamping?: bool; + restDisplacementThreshold?: number; + restSpeedThreshold?: number; + velocity?: number | {x: number, y: number}; + bounciness?: number; + speed?: number; + tension?: number; + friction?: number; +}; + +type SpringAnimationConfigSingle = { + toValue: number | AnimatedValue; + overshootClamping?: bool; + restDisplacementThreshold?: number; + restSpeedThreshold?: number; + velocity?: number; + bounciness?: number; + speed?: number; + tension?: number; + friction?: number; +}; + +function withDefault(value: ?T, defaultValue: T): T { + if (value === undefined || value === null) { + return defaultValue; + } + return value; +} + +class SpringAnimation extends Animation { + _overshootClamping: bool; + _restDisplacementThreshold: number; + _restSpeedThreshold: number; + _initialVelocity: ?number; + _lastVelocity: number; + _startPosition: number; + _lastPosition: number; + _fromValue: number; + _toValue: any; + _tension: number; + _friction: number; + _lastTime: number; + _onUpdate: (value: number) => void; + _animationFrame: any; + + constructor( + config: SpringAnimationConfigSingle, + ) { + 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; + + var 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 = rebound.SpringConfig.fromBouncinessAndSpeed( + withDefault(config.bounciness, 8), + withDefault(config.speed, 12), + ); + } else { + springConfig = rebound.SpringConfig.fromOrigamiTensionAndFriction( + withDefault(config.tension, 40), + withDefault(config.friction, 7), + ); + } + this._tension = springConfig.tension; + this._friction = springConfig.friction; + } + + start( + fromValue: number, + onUpdate: (value: number) => void, + onEnd: ?EndCallback, + previousAnimation: ?Animation, + ): void { + this.__active = true; + this._startPosition = fromValue; + this._lastPosition = this._startPosition; + + this._onUpdate = onUpdate; + this.__onEnd = onEnd; + this._lastTime = Date.now(); + + if (previousAnimation instanceof SpringAnimation) { + var 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(): Object { + return { + lastPosition: this._lastPosition, + lastVelocity: this._lastVelocity, + lastTime: this._lastTime, + }; + } + + onUpdate(): void { + var position = this._lastPosition; + var velocity = this._lastVelocity; + + var tempPosition = this._lastPosition; + var 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. + var MAX_STEPS = 64; + var 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/ + var TIMESTEP_MSEC = 1; + var numSteps = Math.floor((now - this._lastTime) / TIMESTEP_MSEC); + + for (var i = 0; i < numSteps; ++i) { + // Velocity is based on seconds instead of milliseconds + var step = TIMESTEP_MSEC / 1000; + + // This is using RK4. A good blog post to understand how it works: + // http://gafferongames.com/game-physics/integration-basics/ + var aVelocity = velocity; + var aAcceleration = this._tension * (this._toValue - tempPosition) - this._friction * tempVelocity; + var tempPosition = position + aVelocity * step / 2; + var tempVelocity = velocity + aAcceleration * step / 2; + + var bVelocity = tempVelocity; + var bAcceleration = this._tension * (this._toValue - tempPosition) - this._friction * tempVelocity; + tempPosition = position + bVelocity * step / 2; + tempVelocity = velocity + bAcceleration * step / 2; + + var cVelocity = tempVelocity; + var cAcceleration = this._tension * (this._toValue - tempPosition) - this._friction * tempVelocity; + tempPosition = position + cVelocity * step / 2; + tempVelocity = velocity + cAcceleration * step / 2; + + var dVelocity = tempVelocity; + var dAcceleration = this._tension * (this._toValue - tempPosition) - this._friction * tempVelocity; + tempPosition = position + cVelocity * step / 2; + tempVelocity = velocity + cAcceleration * step / 2; + + var dxdt = (aVelocity + 2 * (bVelocity + cVelocity) + dVelocity) / 6; + var 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 + var isOvershooting = false; + if (this._overshootClamping && this._tension !== 0) { + if (this._startPosition < this._toValue) { + isOvershooting = position > this._toValue; + } else { + isOvershooting = position < this._toValue; + } + } + var isVelocity = Math.abs(velocity) <= this._restSpeedThreshold; + var isDisplacement = true; + if (this._tension !== 0) { + isDisplacement = Math.abs(this._toValue - position) <= this._restDisplacementThreshold; + } + if (isOvershooting || (isVelocity && isDisplacement)) { + this.__debouncedOnEnd({finished: true}); + return; + } + this._animationFrame = window.requestAnimationFrame(this.onUpdate.bind(this)); + } + + stop(): void { + this.__active = false; + window.cancelAnimationFrame(this._animationFrame); + this.__debouncedOnEnd({finished: false}); + } +} + +type ValueListenerCallback = (state: {value: number}) => void; + +var _uniqueId = 1; + +class AnimatedValue extends AnimatedWithChildren { + _value: number; + _offset: number; + _animation: ?Animation; + _tracking: ?Animated; + _listeners: {[key: string]: ValueListenerCallback}; + + constructor(value: number) { + super(); + this._value = value; + this._offset = 0; + this._animation = null; + this._listeners = {}; + } + + detach() { + this.stopAnimation(); + } + + __getValue(): number { + return this._value + this._offset; + } + + setValue(value: number): void { + if (this._animation) { + this._animation.stop(); + this._animation = null; + } + this._updateValue(value); + } + + setOffset(offset: number): void { + this._offset = offset; + } + + flattenOffset(): void { + this._value += this._offset; + this._offset = 0; + } + + addListener(callback: ValueListenerCallback): string { + var id = String(_uniqueId++); + this._listeners[id] = callback; + return id; + } + + removeListener(id: string): void { + delete this._listeners[id]; + } + + removeAllListeners(): void { + this._listeners = {}; + } + + animate(animation: Animation, callback: ?EndCallback): void { + var handle = InteractionManager.createInteractionHandle(); + var previousAnimation = this._animation; + this._animation && this._animation.stop(); + this._animation = animation; + animation.start( + this._value, + (value) => { + this._updateValue(value); + }, + (result) => { + this._animation = null; + InteractionManager.clearInteractionHandle(handle); + callback && callback(result); + }, + previousAnimation, + ); + } + + stopAnimation(callback?: ?(value: number) => void): void { + this.stopTracking(); + this._animation && this._animation.stop(); + this._animation = null; + callback && callback(this.__getValue()); + } + + stopTracking(): void { + this._tracking && this._tracking.detach(); + this._tracking = null; + } + + track(tracking: Animated): void { + this.stopTracking(); + this._tracking = tracking; + } + + interpolate(config: InterpolationConfigType): AnimatedInterpolation { + return new AnimatedInterpolation(this, Interpolation.create(config)); + } + + _updateValue(value: number): void { + this._value = value; + _flush(this); + for (var key in this._listeners) { + this._listeners[key]({value: this.__getValue()}); + } + } +} + +type ValueXYListenerCallback = (value: {x: number; y: number}) => void; +class AnimatedValueXY extends AnimatedWithChildren { + x: AnimatedValue; + y: AnimatedValue; + _listeners: {[key: string]: {x: string; y: string}}; + + constructor(valueIn?: ?{x: number | AnimatedValue; y: number | AnimatedValue}) { + super(); + var value: any = 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: {x: number; y: number}) { + this.x.setValue(value.x); + this.y.setValue(value.y); + } + + setOffset(offset: {x: number; y: number}) { + this.x.setOffset(offset.x); + this.y.setOffset(offset.y); + } + + flattenOffset(): void { + this.x.flattenOffset(); + this.y.flattenOffset(); + } + + __getValue(): {x: number; y: number} { + return { + x: this.x.__getValue(), + y: this.y.__getValue(), + }; + } + + stopAnimation(callback?: ?() => number): void { + this.x.stopAnimation(); + this.y.stopAnimation(); + callback && callback(this.__getValue()); + } + + addListener(callback: ValueXYListenerCallback): string { + var id = String(_uniqueId++); + var jointCallback = ({value: number}) => { + callback(this.__getValue()); + }; + this._listeners[id] = { + x: this.x.addListener(jointCallback), + y: this.y.addListener(jointCallback), + }; + return id; + } + + removeListener(id: string): void { + this.x.removeListener(this._listeners[id].x); + this.y.removeListener(this._listeners[id].y); + delete this._listeners[id]; + } + + getLayout(): {[key: string]: AnimatedValue} { + return { + left: this.x, + top: this.y, + }; + } + + getTranslateTransform(): Array<{[key: string]: AnimatedValue}> { + return [ + {translateX: this.x}, + {translateY: this.y} + ]; + } +} + +class AnimatedInterpolation extends AnimatedWithChildren { + _parent: Animated; + _interpolation: (input: number) => number | string; + + constructor(parent: Animated, interpolation: (input: number) => number | string) { + super(); + this._parent = parent; + this._interpolation = interpolation; + } + + __getValue(): number | string { + var parentValue: number = this._parent.__getValue(); + invariant( + typeof parentValue === 'number', + 'Cannot interpolate an input which is not a number.' + ); + return this._interpolation(parentValue); + } + + interpolate(config: InterpolationConfigType): AnimatedInterpolation { + return new AnimatedInterpolation(this, Interpolation.create(config)); + } + + attach(): void { + this._parent.addChild(this); + } + + detach(): void { + this._parent.removeChild(this); + } +} + +class AnimatedTransform extends AnimatedWithChildren { + _transforms: Array; + + constructor(transforms: Array) { + super(); + this._transforms = transforms; + } + + __getValue(): Array { + return this._transforms.map(transform => { + var result = {}; + for (var key in transform) { + var value = transform[key]; + if (value instanceof Animated) { + result[key] = value.__getValue(); + } else { + result[key] = value; + } + } + return result; + }); + } + + getAnimatedValue(): Array { + return this._transforms.map(transform => { + var result = {}; + for (var key in transform) { + var 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(): void { + this._transforms.forEach(transform => { + for (var key in transform) { + var value = transform[key]; + if (value instanceof Animated) { + value.addChild(this); + } + } + }); + } + + detach(): void { + this._transforms.forEach(transform => { + for (var key in transform) { + var value = transform[key]; + if (value instanceof Animated) { + value.removeChild(this); + } + } + }); + } +} + +class AnimatedStyle extends AnimatedWithChildren { + _style: Object; + + constructor(style: any) { + super(); + style = flattenStyle(style) || {}; + if (style.transform) { + style = { + ...style, + transform: new AnimatedTransform(style.transform), + }; + } + this._style = style; + } + + __getValue(): Object { + var style = {}; + for (var key in this._style) { + var value = this._style[key]; + if (value instanceof Animated) { + style[key] = value.__getValue(); + } else { + style[key] = value; + } + } + return style; + } + + getAnimatedValue(): Object { + var style = {}; + for (var key in this._style) { + var value = this._style[key]; + if (value instanceof Animated) { + style[key] = value.getAnimatedValue(); + } + } + return style; + } + + attach(): void { + for (var key in this._style) { + var value = this._style[key]; + if (value instanceof Animated) { + value.addChild(this); + } + } + } + + detach(): void { + for (var key in this._style) { + var value = this._style[key]; + if (value instanceof Animated) { + value.removeChild(this); + } + } + } +} + +class AnimatedProps extends Animated { + _props: Object; + _callback: () => void; + + constructor( + props: Object, + callback: () => void, + ) { + super(); + if (props.style) { + props = { + ...props, + style: new AnimatedStyle(props.style), + }; + } + this._props = props; + this._callback = callback; + this.attach(); + } + + __getValue(): Object { + var props = {}; + for (var key in this._props) { + var value = this._props[key]; + if (value instanceof Animated) { + props[key] = value.__getValue(); + } else { + props[key] = value; + } + } + return props; + } + + getAnimatedValue(): Object { + var props = {}; + for (var key in this._props) { + var value = this._props[key]; + if (value instanceof Animated) { + props[key] = value.getAnimatedValue(); + } + } + return props; + } + + attach(): void { + for (var key in this._props) { + var value = this._props[key]; + if (value instanceof Animated) { + value.addChild(this); + } + } + } + + detach(): void { + for (var key in this._props) { + var value = this._props[key]; + if (value instanceof Animated) { + value.removeChild(this); + } + } + } + + update(): void { + this._callback(); + } +} + +function createAnimatedComponent(Component: any): any { + var refName = 'node'; + + class AnimatedComponent extends React.Component { + _propsAnimated: AnimatedProps; + + componentWillUnmount() { + this._propsAnimated && this._propsAnimated.detach(); + } + + setNativeProps(props) { + this.refs[refName].setNativeProps(props); + } + + componentWillMount() { + this.attachProps(this.props); + } + + attachProps(nextProps) { + var oldPropsAnimated = this._propsAnimated; + + // The system is best designed when setNativeProps is implemented. It is + // able to avoid re-rendering and directly set the attributes that + // changed. However, setNativeProps can only be implemented on leaf + // native components. If you want to animate a composite component, you + // need to re-render it. In this case, we have a fallback that uses + // forceUpdate. + var callback = () => { + if (this.refs[refName].setNativeProps) { + var value = this._propsAnimated.getAnimatedValue(); + this.refs[refName].setNativeProps(value); + } else { + this.forceUpdate(); + } + }; + + this._propsAnimated = new AnimatedProps( + nextProps, + callback, + ); + + // When you call detach, it removes the element from the parent list + // of children. If it goes to 0, then the parent also detaches itself + // and so on. + // An optimization is to attach the new elements and THEN detach the old + // ones instead of detaching and THEN attaching. + // This way the intermediate state isn't to go to 0 and trigger + // this expensive recursive detaching to then re-attach everything on + // the very next operation. + oldPropsAnimated && oldPropsAnimated.detach(); + } + + componentWillReceiveProps(nextProps) { + this.attachProps(nextProps); + } + + render() { + return ( + + ); + } + } + + return AnimatedComponent; +} + +class AnimatedTracking extends Animated { + _value: AnimatedValue; + _parent: Animated; + _callback: ?EndCallback; + _animationConfig: Object; + _animationClass: any; + + constructor( + value: AnimatedValue, + parent: Animated, + animationClass: any, + animationConfig: Object, + callback?: ?EndCallback, + ) { + super(); + this._value = value; + this._parent = parent; + this._animationClass = animationClass; + this._animationConfig = animationConfig; + this._callback = callback; + this.attach(); + } + + __getValue(): Object { + return this._parent.__getValue(); + } + + attach(): void { + this._parent.addChild(this); + } + + detach(): void { + this._parent.removeChild(this); + } + + update(): void { + this._value.animate(new this._animationClass({ + ...this._animationConfig, + toValue: (this._animationConfig.toValue: any).__getValue(), + }), this._callback); + } +} + +type CompositeAnimation = { + start: (callback?: ?EndCallback) => void; + stop: () => void; +}; + +var maybeVectorAnim = function( + value: AnimatedValue | AnimatedValueXY, + config: Object, + anim: (value: AnimatedValue, config: Object) => CompositeAnimation +): ?CompositeAnimation { + if (value instanceof AnimatedValueXY) { + var configX = {...config}; + var configY = {...config}; + for (var key in config) { + var {x, y} = config[key]; + if (x !== undefined && y !== undefined) { + configX[key] = x; + configY[key] = y; + } + } + var aX = anim((value: AnimatedValueXY).x, configX); + var aY = anim((value: AnimatedValueXY).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; +}; + +var spring = function( + value: AnimatedValue | AnimatedValueXY, + config: SpringAnimationConfig, +): CompositeAnimation { + return maybeVectorAnim(value, config, spring) || { + start: function(callback?: ?EndCallback): void { + var singleValue: any = value; + var singleConfig: any = 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: function(): void { + value.stopAnimation(); + }, + }; +}; + +var timing = function( + value: AnimatedValue | AnimatedValueXY, + config: TimingAnimationConfig, +): CompositeAnimation { + return maybeVectorAnim(value, config, timing) || { + start: function(callback?: ?EndCallback): void { + var singleValue: any = value; + var singleConfig: any = 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: function(): void { + value.stopAnimation(); + }, + }; +}; + +var decay = function( + value: AnimatedValue | AnimatedValueXY, + config: DecayAnimationConfig, +): CompositeAnimation { + return maybeVectorAnim(value, config, decay) || { + start: function(callback?: ?EndCallback): void { + var singleValue: any = value; + var singleConfig: any = config; + singleValue.stopTracking(); + singleValue.animate(new DecayAnimation(singleConfig), callback); + }, + + stop: function(): void { + value.stopAnimation(); + }, + }; +}; + +var sequence = function( + animations: Array, +): CompositeAnimation { + var current = 0; + return { + start: function(callback?: ?EndCallback) { + var onComplete = function(result) { + if (!result.finished) { + callback && callback(result); + return; + } + + current++; + + if (current === animations.length) { + callback && callback(result); + return; + } + + animations[current].start(onComplete); + }; + + if (animations.length === 0) { + callback && callback({finished: true}); + } else { + animations[current].start(onComplete); + } + }, + + stop: function() { + if (current < animations.length) { + animations[current].stop(); + } + } + }; +}; + +type ParallelConfig = { + stopTogether?: bool; // If one is stopped, stop all. default: true +} +var parallel = function( + animations: Array, + config?: ?ParallelConfig, +): CompositeAnimation { + var doneCount = 0; + // Make sure we only call stop() at most once for each animation + var hasEnded = {}; + var stopTogether = !(config && config.stopTogether === false); + + var result = { + start: function(callback?: ?EndCallback) { + if (doneCount === animations.length) { + callback && callback({finished: true}); + return; + } + + animations.forEach((animation, idx) => { + animation.start(endResult => { + hasEnded[idx] = true; + doneCount++; + if (doneCount === animations.length) { + doneCount = 0; + callback && callback(endResult); + return; + } + + if (!endResult.finished && stopTogether) { + result.stop(); + } + }); + }); + }, + + stop: function(): void { + animations.forEach((animation, idx) => { + !hasEnded[idx] && animation.stop(); + hasEnded[idx] = true; + }); + } + }; + + return result; +}; + +var delay = function(time: number): CompositeAnimation { + // Would be nice to make a specialized implementation + return timing(new AnimatedValue(0), {toValue: 0, delay: time, duration: 0}); +}; + +var stagger = function( + time: number, + animations: Array, +): CompositeAnimation { + return parallel(animations.map((animation, i) => { + return sequence([ + delay(time * i), + animation, + ]); + })); +}; + +type Mapping = {[key: string]: Mapping} | AnimatedValue; + +/** + * Takes an array of mappings and extracts values from each arg accordingly, + * then calls setValue on the mapped outputs. e.g. + * + * onScroll={this.AnimatedEvent( + * [{nativeEvent: {contentOffset: {x: this._scrollX}}}] + * {listener} // optional listener invoked asynchronously + * ) + * ... + * onPanResponderMove: this.AnimatedEvent([ + * null, // raw event arg + * {dx: this._panX}, // gestureState arg + * ]), + * + */ +type EventConfig = {listener?: ?Function}; +var event = function( + argMapping: Array, + config?: ?EventConfig, +): () => void { + return function(...args): void { + var 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 (var key in recMapping) { + traverse(recMapping[key], recEvt[key], key); + } + }; + argMapping.forEach((mapping, idx) => { + traverse(mapping, args[idx], 'arg' + idx); + }); + if (config && config.listener) { + config.listener.apply(null, args); + } + }; +}; + +module.exports = { + delay, + sequence, + parallel, + stagger, + + decay, + timing, + spring, + + event, + + Value: AnimatedValue, + ValueXY: AnimatedValueXY, + __PropsOnlyForTests: AnimatedProps, + View: createAnimatedComponent(View), + Text: createAnimatedComponent(Text), + Image: createAnimatedComponent(Image), + createAnimatedComponent, +}; diff --git a/Libraries/Animation/Animated/Easing.js b/Libraries/Animation/Animated/Easing.js new file mode 100644 index 000000000..ae90136d1 --- /dev/null +++ b/Libraries/Animation/Animated/Easing.js @@ -0,0 +1,148 @@ +/** + * 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'; + +var bezier = require('bezier'); + +/** + * This class implements common easing functions. The math is pretty obscure, + * but this cool website has nice visual illustrations of what they represent: + * http://xaedes.de/dev/transitions/ + */ +class Easing { + static step0(n) { + return n > 0 ? 1 : 0; + } + + static step1(n) { + return n >= 1 ? 1 : 0; + } + + static linear(t) { + return t; + } + + static ease(t: number): number { + return ease(t); + } + + static quad(t) { + return t * t; + } + + static cubic(t) { + return t * t * t; + } + + static poly(n) { + return (t) => Math.pow(t, n); + } + + static sin(t) { + return 1 - Math.cos(t * Math.PI / 2); + } + + static circle(t) { + return 1 - Math.sqrt(1 - t * t); + } + + static exp(t) { + return Math.pow(2, 10 * (t - 1)); + } + + static elastic(a: number, p: number): (t: number) => number { + var tau = Math.PI * 2; + // flow isn't smart enough to figure out that s is always assigned to a + // number before being used in the returned function + var s: any; + if (arguments.length < 2) { + p = 0.45; + } + if (arguments.length) { + s = p / tau * Math.asin(1 / a); + } else { + a = 1; + s = p / 4; + } + return (t) => 1 + a * Math.pow(2, -10 * t) * Math.sin((t - s) * tau / p); + }; + + static back(s: number): (t: number) => number { + if (s === undefined) { + s = 1.70158; + } + return (t) => t * t * ((s + 1) * t - s); + }; + + 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; + }; + + static bezier( + x1: number, + y1: number, + x2: number, + y2: number, + epsilon?: ?number, + ): (t: number) => number { + if (epsilon === undefined) { + // epsilon determines the precision of the solved values + // a good approximation is: + var duration = 500; // duration of animation in milliseconds. + epsilon = (1000 / 60 / duration) / 4; + } + + return bezier(x1, y1, x2, y2, epsilon); + } + + static in( + easing: (t: number) => number, + ): (t: number) => number { + return easing; + } + + static out( + easing: (t: number) => number, + ): (t: number) => number { + return (t) => 1 - easing(1 - t); + } + + 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; + }; + } +} + +var ease = Easing.bezier(0.42, 0, 1, 1); + +module.exports = Easing; diff --git a/Libraries/Animation/Animated/Interpolation.js b/Libraries/Animation/Animated/Interpolation.js new file mode 100644 index 000000000..bed22ec85 --- /dev/null +++ b/Libraries/Animation/Animated/Interpolation.js @@ -0,0 +1,258 @@ +/** + * 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 Interpolation + * @flow + */ +'use strict'; + +var invariant = require('invariant'); + +type ExtrapolateType = 'extend' | 'identity' | 'clamp'; + +// $FlowFixMe D2163827 +export type InterpolationConfigType = { + inputRange: Array; + outputRange: (Array | Array); + easing?: ((input: number) => number); + extrapolate?: ExtrapolateType; + extrapolateLeft?: ExtrapolateType; + extrapolateRight?: ExtrapolateType; +}; + +var linear = (t) => t; + +/** + * Very handy helper to map input ranges to output ranges with an easing + * function and custom behavior outside of the ranges. + */ +class Interpolation { + static create(config: InterpolationConfigType): (input: number) => number | string { + + if (config.outputRange && typeof config.outputRange[0] === 'string') { + return createInterpolationFromStringOutputRange(config); + } + + var outputRange: Array = (config.outputRange: any); + checkInfiniteRange('outputRange', outputRange); + + var inputRange = config.inputRange; + checkInfiniteRange('inputRange', inputRange); + checkValidInputRange(inputRange); + + invariant( + inputRange.length === outputRange.length, + 'inputRange (' + inputRange.length + ') and outputRange (' + + outputRange.length + ') must have the same length' + ); + + var easing = config.easing || linear; + + var extrapolateLeft: ExtrapolateType = 'extend'; + if (config.extrapolateLeft !== undefined) { + extrapolateLeft = config.extrapolateLeft; + } else if (config.extrapolate !== undefined) { + extrapolateLeft = config.extrapolate; + } + + var extrapolateRight: ExtrapolateType = 'extend'; + if (config.extrapolateRight !== undefined) { + extrapolateRight = config.extrapolateRight; + } else if (config.extrapolate !== undefined) { + extrapolateRight = config.extrapolate; + } + + return (input) => { + invariant( + typeof input === 'number', + 'Cannot interpolation an input which is not a number' + ); + + var range = findRange(input, inputRange); + return interpolate( + input, + inputRange[range], + inputRange[range + 1], + outputRange[range], + outputRange[range + 1], + easing, + extrapolateLeft, + extrapolateRight, + ); + }; + } +} + +function interpolate( + input: number, + inputMin: number, + inputMax: number, + outputMin: number, + outputMax: number, + easing: ((input: number) => number), + extrapolateLeft: ExtrapolateType, + extrapolateRight: ExtrapolateType, +) { + var result = input; + + // Extrapolate + if (result < inputMin) { + if (extrapolateLeft === 'identity') { + return result; + } else if (extrapolateLeft === 'clamp') { + result = inputMin; + } else if (extrapolateLeft === 'extend') { + // noop + } + } + + if (result > inputMax) { + if (extrapolateRight === 'identity') { + return result; + } else if (extrapolateRight === 'clamp') { + result = inputMax; + } else if (extrapolateRight === 'extend') { + // noop + } + } + + if (outputMin === outputMax) { + return outputMin; + } + + if (inputMin === inputMax) { + if (input <= inputMin) { + return outputMin; + } + return outputMax; + } + + // Input Range + if (inputMin === -Infinity) { + result = -result; + } else if (inputMax === Infinity) { + result = result - inputMin; + } else { + result = (result - inputMin) / (inputMax - inputMin); + } + + // Easing + result = easing(result); + + // Output Range + if (outputMin === -Infinity) { + result = -result; + } else if (outputMax === Infinity) { + result = result + outputMin; + } else { + result = result * (outputMax - outputMin) + outputMin; + } + + return result; +} + +var stringShapeRegex = /[0-9\.-]+/g; + +/** + * Supports string shapes by extracting numbers so new values can be computed, + * and recombines those values into new strings of the same shape. Supports + * things like: + * + * rgba(123, 42, 99, 0.36) // colors + * -45deg // values with units + */ +function createInterpolationFromStringOutputRange( + config: InterpolationConfigType, +): (input: number) => string { + var outputRange: Array = (config.outputRange: any); + invariant(outputRange.length >= 2, 'Bad output range'); + checkPattern(outputRange); + + // ['rgba(0, 100, 200, 0)', 'rgba(50, 150, 250, 0.5)'] + // -> + // [ + // [0, 50], + // [100, 150], + // [200, 250], + // [0, 0.5], + // ] + var outputRanges = outputRange[0].match(stringShapeRegex).map(() => []); + outputRange.forEach(value => { + value.match(stringShapeRegex).forEach((number, i) => { + outputRanges[i].push(+number); + }); + }); + + var interpolations = outputRange[0].match(stringShapeRegex).map((value, i) => { + return Interpolation.create({ + ...config, + outputRange: outputRanges[i], + }); + }); + + return (input) => { + var i = 0; + // 'rgba(0, 100, 200, 0)' + // -> + // 'rgba(${interpolations[0](input)}, ${interpolations[1](input)}, ...' + return outputRange[0].replace(stringShapeRegex, () => { + return String(interpolations[i++](input)); + }); + }; +} + +function checkPattern(arr: Array) { + var pattern = arr[0].replace(stringShapeRegex, ''); + for (var i = 1; i < arr.length; ++i) { + invariant( + pattern === arr[i].replace(stringShapeRegex, ''), + 'invalid pattern ' + arr[0] + ' and ' + arr[i], + ); + } +} + +function findRange(input: number, inputRange: Array) { + for (var i = 1; i < inputRange.length - 1; ++i) { + if (inputRange[i] >= input) { + break; + } + } + return i - 1; +} + +function checkValidInputRange(arr: Array) { + invariant(arr.length >= 2, 'inputRange must have at least 2 elements'); + for (var i = 1; i < arr.length; ++i) { + invariant( + arr[i] >= arr[i - 1], + /* $FlowFixMe(>=0.13.0) - In the addition expression below this comment, + * one or both of the operands may be something that doesn't cleanly + * convert to a string, like undefined, null, and object, etc. If you really + * mean this implicit string conversion, you can do something like + * String(myThing) + */ + 'inputRange must be monolithically increasing ' + arr + ); + } +} + +function checkInfiniteRange(name: string, arr: Array) { + invariant(arr.length >= 2, name + ' must have at least 2 elements'); + invariant( + arr.length !== 2 || arr[0] !== -Infinity || arr[1] !== Infinity, + /* $FlowFixMe(>=0.13.0) - In the addition expression below this comment, + * one or both of the operands may be something that doesn't cleanly convert + * to a string, like undefined, null, and object, etc. If you really mean + * this implicit string conversion, you can do something like + * String(myThing) + */ + name + 'cannot be ]-infinity;+infinity[ ' + arr + ); +} + +module.exports = Interpolation; diff --git a/Libraries/Animation/Animated/__tests__/Animated-test.js b/Libraries/Animation/Animated/__tests__/Animated-test.js new file mode 100644 index 000000000..bd9ef68e5 --- /dev/null +++ b/Libraries/Animation/Animated/__tests__/Animated-test.js @@ -0,0 +1,449 @@ +/** + * 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. + */ +'use strict'; + +jest + .autoMockOff() + .setMock('Text', {}) + .setMock('View', {}) + .setMock('Image', {}) + .setMock('React', {Component: class {}}); + +var Animated = require('Animated'); + +describe('Animated', () => { + it('works end to end', () => { + var anim = new Animated.Value(0); + + var callback = jest.genMockFunction(); + + var node = new Animated.__PropsOnlyForTests({ + style: { + backgroundColor: 'red', + opacity: anim, + transform: [ + {translateX: anim.interpolate({ + inputRange: [0, 1], + outputRange: [100, 200], + })}, + {scale: anim}, + ] + } + }, callback); + + expect(anim.getChildren().length).toBe(3); + + expect(node.__getValue()).toEqual({ + style: { + backgroundColor: 'red', + opacity: 0, + transform: [ + {translateX: 100}, + {scale: 0}, + ], + }, + }); + + anim.setValue(0.5); + + expect(callback).toBeCalled(); + + expect(node.__getValue()).toEqual({ + style: { + backgroundColor: 'red', + opacity: 0.5, + transform: [ + {translateX: 150}, + {scale: 0.5}, + ], + }, + }); + + node.detach(); + expect(anim.getChildren().length).toBe(0); + + anim.setValue(1); + expect(callback.mock.calls.length).toBe(1); + }); + + it('does not detach on updates', () => { + var anim = new Animated.Value(0); + anim.detach = jest.genMockFunction(); + + var c = new Animated.View(); + c.props = { + style: { + opacity: anim, + }, + }; + c.componentWillMount(); + + expect(anim.detach).not.toBeCalled(); + c.componentWillReceiveProps({ + style: { + opacity: anim, + }, + }); + expect(anim.detach).not.toBeCalled(); + + c.componentWillUnmount(); + expect(anim.detach).toBeCalled(); + }); + + + it('stops animation when detached', () => { + // jest environment doesn't have requestAnimationFrame :( + window.requestAnimationFrame = jest.genMockFunction(); + window.cancelAnimationFrame = jest.genMockFunction(); + + var anim = new Animated.Value(0); + var callback = jest.genMockFunction(); + + var c = new Animated.View(); + c.props = { + style: { + opacity: anim, + }, + }; + c.componentWillMount(); + + Animated.timing(anim, {toValue: 10, duration: 1000}).start(callback); + + c.componentWillUnmount(); + + expect(callback).toBeCalledWith({finished: false}); + expect(callback).toBeCalledWith({finished: false}); + }); + + it('triggers callback when spring is at rest', () => { + var anim = new Animated.Value(0); + var callback = jest.genMockFunction(); + Animated.spring(anim, {toValue: 0, velocity: 0}).start(callback); + expect(callback).toBeCalled(); + }); +}); + + +describe('Animated Sequence', () => { + + it('works with an empty sequence', () => { + var cb = jest.genMockFunction(); + Animated.sequence([]).start(cb); + expect(cb).toBeCalledWith({finished: true}); + }); + + it('sequences well', () => { + var anim1 = {start: jest.genMockFunction()}; + var anim2 = {start: jest.genMockFunction()}; + var cb = jest.genMockFunction(); + + var seq = Animated.sequence([anim1, anim2]); + + expect(anim1.start).not.toBeCalled(); + expect(anim2.start).not.toBeCalled(); + + seq.start(cb); + + expect(anim1.start).toBeCalled(); + expect(anim2.start).not.toBeCalled(); + expect(cb).not.toBeCalled(); + + anim1.start.mock.calls[0][0]({finished: true}); + + expect(anim2.start).toBeCalled(); + expect(cb).not.toBeCalled(); + + anim2.start.mock.calls[0][0]({finished: true}); + expect(cb).toBeCalledWith({finished: true}); + }); + + it('supports interrupting sequence', () => { + var anim1 = {start: jest.genMockFunction()}; + var anim2 = {start: jest.genMockFunction()}; + var cb = jest.genMockFunction(); + + Animated.sequence([anim1, anim2]).start(cb); + + anim1.start.mock.calls[0][0]({finished: false}); + + expect(anim1.start).toBeCalled(); + expect(anim2.start).not.toBeCalled(); + expect(cb).toBeCalledWith({finished: false}); + }); + + it('supports stopping sequence', () => { + var anim1 = {start: jest.genMockFunction(), stop: jest.genMockFunction()}; + var anim2 = {start: jest.genMockFunction(), stop: jest.genMockFunction()}; + var cb = jest.genMockFunction(); + + var seq = Animated.sequence([anim1, anim2]); + seq.start(cb); + seq.stop(); + + expect(anim1.stop).toBeCalled(); + expect(anim2.stop).not.toBeCalled(); + expect(cb).not.toBeCalled(); + + anim1.start.mock.calls[0][0]({finished: false}); + + expect(cb).toBeCalledWith({finished: false}); + }); +}); + + +describe('Animated Parallel', () => { + + it('works with an empty parallel', () => { + var cb = jest.genMockFunction(); + Animated.parallel([]).start(cb); + expect(cb).toBeCalledWith({finished: true}); + }); + + + it('parellelizes well', () => { + var anim1 = {start: jest.genMockFunction()}; + var anim2 = {start: jest.genMockFunction()}; + var cb = jest.genMockFunction(); + + var par = Animated.parallel([anim1, anim2]); + + expect(anim1.start).not.toBeCalled(); + expect(anim2.start).not.toBeCalled(); + + par.start(cb); + + expect(anim1.start).toBeCalled(); + expect(anim2.start).toBeCalled(); + expect(cb).not.toBeCalled(); + + anim1.start.mock.calls[0][0]({finished: true}); + expect(cb).not.toBeCalled(); + + anim2.start.mock.calls[0][0]({finished: true}); + expect(cb).toBeCalledWith({finished: true}); + }); + + it('supports stopping parallel', () => { + var anim1 = {start: jest.genMockFunction(), stop: jest.genMockFunction()}; + var anim2 = {start: jest.genMockFunction(), stop: jest.genMockFunction()}; + var cb = jest.genMockFunction(); + + var seq = Animated.parallel([anim1, anim2]); + seq.start(cb); + seq.stop(); + + expect(anim1.stop).toBeCalled(); + expect(anim2.stop).toBeCalled(); + expect(cb).not.toBeCalled(); + + anim1.start.mock.calls[0][0]({finished: false}); + expect(cb).not.toBeCalled(); + + anim2.start.mock.calls[0][0]({finished: false}); + expect(cb).toBeCalledWith({finished: false}); + }); + + + it('does not call stop more than once when stopping', () => { + var anim1 = {start: jest.genMockFunction(), stop: jest.genMockFunction()}; + var anim2 = {start: jest.genMockFunction(), stop: jest.genMockFunction()}; + var anim3 = {start: jest.genMockFunction(), stop: jest.genMockFunction()}; + var cb = jest.genMockFunction(); + + var seq = Animated.parallel([anim1, anim2, anim3]); + seq.start(cb); + + anim1.start.mock.calls[0][0]({finished: false}); + + expect(anim1.stop.mock.calls.length).toBe(0); + expect(anim2.stop.mock.calls.length).toBe(1); + expect(anim3.stop.mock.calls.length).toBe(1); + + anim2.start.mock.calls[0][0]({finished: false}); + + expect(anim1.stop.mock.calls.length).toBe(0); + expect(anim2.stop.mock.calls.length).toBe(1); + expect(anim3.stop.mock.calls.length).toBe(1); + + anim3.start.mock.calls[0][0]({finished: false}); + + expect(anim1.stop.mock.calls.length).toBe(0); + expect(anim2.stop.mock.calls.length).toBe(1); + expect(anim3.stop.mock.calls.length).toBe(1); + }); +}); + +describe('Animated Events', () => { + it('should map events', () => { + var value = new Animated.Value(0); + var handler = Animated.event( + [null, {state: {foo: value}}], + ); + handler({bar: 'ignoreBar'}, {state: {baz: 'ignoreBaz', foo: 42}}); + expect(value.__getValue()).toBe(42); + }); + it('should call listeners', () => { + var value = new Animated.Value(0); + var listener = jest.genMockFunction(); + var handler = Animated.event( + [{foo: value}], + {listener}, + ); + handler({foo: 42}); + expect(value.__getValue()).toBe(42); + expect(listener.mock.calls.length).toBe(1); + expect(listener).toBeCalledWith({foo: 42}); + }); +}); + +describe('Animated Tracking', () => { + it('should track values', () => { + var value1 = new Animated.Value(0); + var value2 = new Animated.Value(0); + Animated.timing(value2, { + toValue: value1, + duration: 0, + }).start(); + value1.setValue(42); + expect(value2.__getValue()).toBe(42); + value1.setValue(7); + expect(value2.__getValue()).toBe(7); + }); + + it('should track interpolated values', () => { + var value1 = new Animated.Value(0); + var value2 = new Animated.Value(0); + Animated.timing(value2, { + toValue: value1.interpolate({ + inputRange: [0, 2], + outputRange: [0, 1] + }), + duration: 0, + }).start(); + value1.setValue(42); + expect(value2.__getValue()).toBe(42 / 2); + }); + + it('should stop tracking when animated', () => { + var value1 = new Animated.Value(0); + var value2 = new Animated.Value(0); + Animated.timing(value2, { + toValue: value1, + duration: 0, + }).start(); + value1.setValue(42); + expect(value2.__getValue()).toBe(42); + Animated.timing(value2, { + toValue: 7, + duration: 0, + }).start(); + value1.setValue(1492); + expect(value2.__getValue()).toBe(7); + }); +}); + +describe('Animated Vectors', () => { + it('should animate vectors', () => { + var vec = new Animated.ValueXY(); + + var callback = jest.genMockFunction(); + + var node = new Animated.__PropsOnlyForTests({ + style: { + opacity: vec.x.interpolate({ + inputRange: [0, 42], + outputRange: [0.2, 0.8], + }), + transform: vec.getTranslateTransform(), + ...vec.getLayout(), + } + }, callback); + + expect(node.__getValue()).toEqual({ + style: { + opacity: 0.2, + transform: [ + {translateX: 0}, + {translateY: 0}, + ], + left: 0, + top: 0, + }, + }); + + vec.setValue({x: 42, y: 1492}); + + expect(callback.mock.calls.length).toBe(2); // once each for x, y + + expect(node.__getValue()).toEqual({ + style: { + opacity: 0.8, + transform: [ + {translateX: 42}, + {translateY: 1492}, + ], + left: 42, + top: 1492, + }, + }); + + node.detach(); + + vec.setValue({x: 1, y: 1}); + expect(callback.mock.calls.length).toBe(2); + }); + + it('should track vectors', () => { + var value1 = new Animated.ValueXY(); + var value2 = new Animated.ValueXY(); + Animated.timing(value2, { + toValue: value1, + duration: 0, + }).start(); + value1.setValue({x: 42, y: 1492}); + expect(value2.__getValue()).toEqual({x: 42, y: 1492}); + + // Make sure tracking keeps working (see stopTogether in ParallelConfig used + // by maybeVectorAnim). + value1.setValue({x: 3, y: 4}); + expect(value2.__getValue()).toEqual({x: 3, y: 4}); + }); +}); + +describe('Animated Listeners', () => { + it('should get updates', () => { + var value1 = new Animated.Value(0); + var listener = jest.genMockFunction(); + var id = value1.addListener(listener); + value1.setValue(42); + expect(listener.mock.calls.length).toBe(1); + expect(listener).toBeCalledWith({value: 42}); + expect(value1.__getValue()).toBe(42); + value1.setValue(7); + expect(listener.mock.calls.length).toBe(2); + expect(listener).toBeCalledWith({value: 7}); + expect(value1.__getValue()).toBe(7); + value1.removeListener(id); + value1.setValue(1492); + expect(listener.mock.calls.length).toBe(2); + expect(value1.__getValue()).toBe(1492); + }); + + it('should removeAll', () => { + var value1 = new Animated.Value(0); + var listener = jest.genMockFunction(); + [1,2,3,4].forEach(() => value1.addListener(listener)); + value1.setValue(42); + expect(listener.mock.calls.length).toBe(4); + expect(listener).toBeCalledWith({value: 42}); + value1.removeAllListeners(); + value1.setValue(7); + expect(listener.mock.calls.length).toBe(4); + }); +}); diff --git a/Libraries/Animation/Animated/__tests__/Easing-test.js b/Libraries/Animation/Animated/__tests__/Easing-test.js new file mode 100644 index 000000000..bee894d6f --- /dev/null +++ b/Libraries/Animation/Animated/__tests__/Easing-test.js @@ -0,0 +1,119 @@ +/** + * 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. + */ +'use strict'; + +jest.dontMock('Easing'); + +var Easing = require('Easing'); +describe('Easing', () => { + it('should work with linear', () => { + var easing = Easing.linear; + + expect(easing(0)).toBe(0); + expect(easing(0.5)).toBe(0.5); + expect(easing(0.8)).toBe(0.8); + expect(easing(1)).toBe(1); + }); + + it('should work with ease in linear', () => { + var easing = Easing.in(Easing.linear); + expect(easing(0)).toBe(0); + expect(easing(0.5)).toBe(0.5); + expect(easing(0.8)).toBe(0.8); + expect(easing(1)).toBe(1); + }); + + it('should work with easy out linear', () => { + var easing = Easing.out(Easing.linear); + expect(easing(0)).toBe(0); + expect(easing(0.5)).toBe(0.5); + expect(easing(0.6)).toBe(0.6); + expect(easing(1)).toBe(1); + }); + + it('should work with ease in quad', () => { + function easeInQuad(t) { + return t * t; + } + var easing = Easing.in(Easing.quad); + for (var t = -0.5; t < 1.5; t += 0.1) { + expect(easing(t)).toBe(easeInQuad(t)); + } + }); + + it('should work with ease out quad', () => { + function easeOutQuad(t) { + return -t * (t - 2); + } + var easing = Easing.out(Easing.quad); + for (var t = 0; t <= 1; t += 0.1) { + expect(easing(1)).toBe(easeOutQuad(1)); + } + }); + + it('should work with ease in-out quad', () => { + function easeInOutQuad(t) { + t = t * 2; + if (t < 1) { + return 0.5 * t * t; + } + return -((t - 1) * (t - 3) - 1) / 2; + } + var easing = Easing.inOut(Easing.quad); + for (var t = -0.5; t < 1.5; t += 0.1) { + expect(easing(t)).toBeCloseTo(easeInOutQuad(t), 4); + } + }); + + function sampleEasingFunction(easing) { + var DURATION = 300; + var tickCount = Math.round(DURATION * 60 / 1000); + var samples = []; + for (var i = 0; i <= tickCount; i++) { + samples.push(easing(i / tickCount)); + } + return samples; + } + + var Samples = { + in_quad: [0,0.0030864197530864196,0.012345679012345678,0.027777777777777776,0.04938271604938271,0.0771604938271605,0.1111111111111111,0.15123456790123457,0.19753086419753085,0.25,0.308641975308642,0.37345679012345684,0.4444444444444444,0.5216049382716049,0.6049382716049383,0.6944444444444445,0.7901234567901234,0.8919753086419753,1], + out_quad: [0,0.10802469135802469,0.20987654320987653,0.3055555555555555,0.3950617283950617,0.47839506172839513,0.5555555555555556,0.6265432098765432,0.691358024691358,0.75,0.8024691358024691,0.8487654320987654,0.888888888888889,0.9228395061728394,0.9506172839506174,0.9722222222222221,0.9876543209876543,0.9969135802469136,1], + inOut_quad: [0,0.006172839506172839,0.024691358024691357,0.05555555555555555,0.09876543209876543,0.154320987654321,0.2222222222222222,0.30246913580246915,0.3950617283950617,0.5,0.6049382716049383,0.697530864197531,0.7777777777777777,0.845679012345679,0.9012345679012346,0.9444444444444444,0.9753086419753086,0.9938271604938271,1], + in_cubic: [0,0.00017146776406035664,0.0013717421124828531,0.004629629629629629,0.010973936899862825,0.021433470507544586,0.037037037037037035,0.05881344307270234,0.0877914951989026,0.125,0.1714677640603567,0.22822359396433475,0.2962962962962963,0.37671467764060357,0.4705075445816187,0.5787037037037038,0.7023319615912208,0.8424211248285322,1], + out_cubic: [0,0.15757887517146785,0.2976680384087792,0.42129629629629617,0.5294924554183813,0.6232853223593964,0.7037037037037036,0.7717764060356652,0.8285322359396433,0.875,0.9122085048010974,0.9411865569272977,0.9629629629629629,0.9785665294924554,0.9890260631001372,0.9953703703703703,0.9986282578875172,0.9998285322359396,1], + inOut_cubic: [0,0.0006858710562414266,0.0054869684499314125,0.018518518518518517,0.0438957475994513,0.08573388203017834,0.14814814814814814,0.23525377229080935,0.3511659807956104,0.5,0.6488340192043895,0.7647462277091908,0.8518518518518519,0.9142661179698217,0.9561042524005487,0.9814814814814815,0.9945130315500685,0.9993141289437586,1], + in_sin: [0,0.003805301908254455,0.01519224698779198,0.03407417371093169,0.06030737921409157,0.09369221296335006,0.1339745962155613,0.1808479557110082,0.233955556881022,0.2928932188134524,0.35721239031346064,0.42642356364895384,0.4999999999999999,0.5773817382593005,0.6579798566743311,0.7411809548974793,0.8263518223330696,0.9128442572523416,0.9999999999999999], + out_sin: [0,0.08715574274765817,0.17364817766693033,0.25881904510252074,0.3420201433256687,0.42261826174069944,0.49999999999999994,0.573576436351046,0.6427876096865393,0.7071067811865475,0.766044443118978,0.8191520442889918,0.8660254037844386,0.9063077870366499,0.9396926207859083,0.9659258262890683,0.984807753012208,0.9961946980917455,1], + inOut_sin: [0,0.00759612349389599,0.030153689607045786,0.06698729810778065,0.116977778440511,0.17860619515673032,0.24999999999999994,0.32898992833716556,0.4131759111665348,0.49999999999999994,0.5868240888334652,0.6710100716628343,0.7499999999999999,0.8213938048432696,0.883022221559489,0.9330127018922194,0.9698463103929542,0.9924038765061041,1], + in_exp: [0,0.0014352875901128893,0.002109491677524035,0.0031003926796253885,0.004556754060844206,0.006697218616039631,0.009843133202303688,0.014466792379488908,0.021262343752724643,0.03125,0.045929202883612456,0.06750373368076916,0.09921256574801243,0.1458161299470146,0.2143109957132682,0.31498026247371835,0.46293735614364506,0.6803950000871883,1], + out_exp: [0,0.31960499991281155,0.5370626438563548,0.6850197375262816,0.7856890042867318,0.8541838700529854,0.9007874342519875,0.9324962663192309,0.9540707971163875,0.96875,0.9787376562472754,0.9855332076205111,0.9901568667976963,0.9933027813839603,0.9954432459391558,0.9968996073203746,0.9978905083224759,0.9985647124098871,1], + inOut_exp: [0,0.0010547458387620175,0.002278377030422103,0.004921566601151844,0.010631171876362321,0.022964601441806228,0.049606282874006216,0.1071554978566341,0.23146867807182253,0.5,0.7685313219281775,0.892844502143366,0.9503937171259937,0.9770353985581938,0.9893688281236377,0.9950784333988482,0.9977216229695779,0.998945254161238,1], + in_circle: [0,0.0015444024660317135,0.006192010000093506,0.013986702816730645,0.025003956956430873,0.03935464078941209,0.057190958417936644,0.07871533601238889,0.10419358352238339,0.1339745962155614,0.1685205807169019,0.20845517506805522,0.2546440075000701,0.3083389112228482,0.37146063894529113,0.4472292016074334,0.5418771527091488,0.6713289009389102,1], + out_circle: [0,0.3286710990610898,0.45812284729085123,0.5527707983925666,0.6285393610547089,0.6916610887771518,0.7453559924999298,0.7915448249319448,0.8314794192830981,0.8660254037844386,0.8958064164776166,0.9212846639876111,0.9428090415820634,0.9606453592105879,0.9749960430435691,0.9860132971832694,0.9938079899999065,0.9984555975339683,1], + inOut_circle: [0,0.003096005000046753,0.012501978478215436,0.028595479208968322,0.052096791761191696,0.08426029035845095,0.12732200375003505,0.18573031947264557,0.2709385763545744,0.5,0.7290614236454256,0.8142696805273546,0.8726779962499649,0.915739709641549,0.9479032082388084,0.9714045207910317,0.9874980215217846,0.9969039949999532,1], + in_back_: [0,-0.004788556241426612,-0.017301289437585736,-0.0347587962962963,-0.05438167352537723,-0.07339051783264748,-0.08900592592592595,-0.09844849451303156,-0.0989388203017833,-0.08769750000000004,-0.06194513031550073,-0.018902307956104283,0.044210370370370254,0.13017230795610413,0.2417629080932785,0.3817615740740742,0.5529477091906719,0.7581007167352535,0.9999999999999998], + out_back_: [2.220446049250313e-16,0.24189928326474652,0.44705229080932807,0.6182384259259258,0.7582370919067215,0.8698276920438959,0.9557896296296297,1.0189023079561044,1.0619451303155008,1.0876975,1.0989388203017834,1.0984484945130315,1.089005925925926,1.0733905178326475,1.0543816735253773,1.0347587962962963,1.0173012894375857,1.0047885562414267,1], + }; + + Object.keys(Samples).forEach(function(type) { + it('should ease ' + type, function() { + var [modeName, easingName, isFunction] = type.split('_'); + var easing = Easing[easingName]; + if (isFunction !== undefined) { + easing = easing(); + } + var computed = sampleEasingFunction(Easing[modeName](easing)); + var samples = Samples[type]; + + computed.forEach((value, key) => { + expect(value).toBeCloseTo(samples[key], 2); + }); + }); + }); +}); diff --git a/Libraries/Animation/Animated/__tests__/Interpolation-test.js b/Libraries/Animation/Animated/__tests__/Interpolation-test.js new file mode 100644 index 000000000..aec20ba2b --- /dev/null +++ b/Libraries/Animation/Animated/__tests__/Interpolation-test.js @@ -0,0 +1,256 @@ +/** + * 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. + */ +'use strict'; + +jest + .dontMock('Interpolation') + .dontMock('Easing'); + +var Interpolation = require('Interpolation'); +var Easing = require('Easing'); + +describe('Interpolation', () => { + it('should work with defaults', () => { + var interpolation = Interpolation.create({ + inputRange: [0, 1], + outputRange: [0, 1], + }); + + expect(interpolation(0)).toBe(0); + expect(interpolation(0.5)).toBe(0.5); + expect(interpolation(0.8)).toBe(0.8); + expect(interpolation(1)).toBe(1); + }); + + it('should work with output range', () => { + var interpolation = Interpolation.create({ + inputRange: [0, 1], + outputRange: [100, 200], + }); + + expect(interpolation(0)).toBe(100); + expect(interpolation(0.5)).toBe(150); + expect(interpolation(0.8)).toBe(180); + expect(interpolation(1)).toBe(200); + }); + + it('should work with input range', () => { + var interpolation = Interpolation.create({ + inputRange: [100, 200], + outputRange: [0, 1], + }); + + expect(interpolation(100)).toBe(0); + expect(interpolation(150)).toBe(0.5); + expect(interpolation(180)).toBe(0.8); + expect(interpolation(200)).toBe(1); + }); + + it('should throw for non monotonic input ranges', () => { + expect(() => Interpolation.create({ + inputRange: [0, 2, 1], + outputRange: [0, 1, 2], + })).toThrow(); + + expect(() => Interpolation.create({ + inputRange: [0, 1, 2], + outputRange: [0, 3, 1], + })).not.toThrow(); + }); + + it('should work with empty input range', () => { + var interpolation = Interpolation.create({ + inputRange: [0, 10, 10], + outputRange: [1, 2, 3], + extrapolate: 'extend', + }); + + expect(interpolation(0)).toBe(1); + expect(interpolation(5)).toBe(1.5); + expect(interpolation(10)).toBe(2); + expect(interpolation(10.1)).toBe(3); + expect(interpolation(15)).toBe(3); + }); + + it('should work with empty output range', () => { + var interpolation = Interpolation.create({ + inputRange: [1, 2, 3], + outputRange: [0, 10, 10], + extrapolate: 'extend', + }); + + expect(interpolation(0)).toBe(-10); + expect(interpolation(1.5)).toBe(5); + expect(interpolation(2)).toBe(10); + expect(interpolation(2.5)).toBe(10); + expect(interpolation(3)).toBe(10); + expect(interpolation(4)).toBe(10); + }); + + it('should work with easing', () => { + var interpolation = Interpolation.create({ + inputRange: [0, 1], + outputRange: [0, 1], + easing: Easing.quad, + }); + + expect(interpolation(0)).toBe(0); + expect(interpolation(0.5)).toBe(0.25); + expect(interpolation(0.9)).toBe(0.81); + expect(interpolation(1)).toBe(1); + }); + + it('should work with extrapolate', () => { + var interpolation = Interpolation.create({ + inputRange: [0, 1], + outputRange: [0, 1], + extrapolate: 'extend', + easing: Easing.quad, + }); + + expect(interpolation(-2)).toBe(4); + expect(interpolation(2)).toBe(4); + + interpolation = Interpolation.create({ + inputRange: [0, 1], + outputRange: [0, 1], + extrapolate: 'clamp', + easing: Easing.quad, + }); + + expect(interpolation(-2)).toBe(0); + expect(interpolation(2)).toBe(1); + + interpolation = Interpolation.create({ + inputRange: [0, 1], + outputRange: [0, 1], + extrapolate: 'identity', + easing: Easing.quad, + }); + + expect(interpolation(-2)).toBe(-2); + expect(interpolation(2)).toBe(2); + }); + + it('should work with keyframes with extrapolate', () => { + var interpolation = Interpolation.create({ + inputRange: [0, 10, 100, 1000], + outputRange: [0, 5, 50, 500], + extrapolate: true, + }); + + expect(interpolation(-5)).toBe(-2.5); + expect(interpolation(0)).toBe(0); + expect(interpolation(5)).toBe(2.5); + expect(interpolation(10)).toBe(5); + expect(interpolation(50)).toBe(25); + expect(interpolation(100)).toBe(50); + expect(interpolation(500)).toBe(250); + expect(interpolation(1000)).toBe(500); + expect(interpolation(2000)).toBe(1000); + }); + + it('should work with keyframes without extrapolate', () => { + var interpolation = Interpolation.create({ + inputRange: [0, 1, 2], + outputRange: [0.2, 1, 0.2], + extrapolate: 'clamp', + }); + + expect(interpolation(5)).toBeCloseTo(0.2); + }); + + it('should throw for an infinite input range', () => { + expect(() => Interpolation.create({ + inputRange: [-Infinity, Infinity], + outputRange: [0, 1], + })).toThrow(); + + expect(() => Interpolation.create({ + inputRange: [-Infinity, 0, Infinity], + outputRange: [1, 2, 3], + })).not.toThrow(); + }); + + it('should work with negative infinite', () => { + var interpolation = Interpolation.create({ + inputRange: [-Infinity, 0], + outputRange: [-Infinity, 0], + easing: Easing.quad, + extrapolate: 'identity', + }); + + expect(interpolation(-Infinity)).toBe(-Infinity); + expect(interpolation(-100)).toBeCloseTo(-10000); + expect(interpolation(-10)).toBeCloseTo(-100); + expect(interpolation(0)).toBeCloseTo(0); + expect(interpolation(1)).toBeCloseTo(1); + expect(interpolation(100)).toBeCloseTo(100); + }); + + it('should work with positive infinite', () => { + var interpolation = Interpolation.create({ + inputRange: [5, Infinity], + outputRange: [5, Infinity], + easing: Easing.quad, + extrapolate: 'identity', + }); + + expect(interpolation(-100)).toBeCloseTo(-100); + expect(interpolation(-10)).toBeCloseTo(-10); + expect(interpolation(0)).toBeCloseTo(0); + expect(interpolation(5)).toBeCloseTo(5); + expect(interpolation(6)).toBeCloseTo(5 + 1); + expect(interpolation(10)).toBeCloseTo(5 + 25); + expect(interpolation(100)).toBeCloseTo(5 + (95 * 95)); + expect(interpolation(Infinity)).toBe(Infinity); + }); + + it('should work with output ranges as string', () => { + var interpolation = Interpolation.create({ + inputRange: [0, 1], + outputRange: ['rgba(0, 100, 200, 0)', 'rgba(50, 150, 250, 0.5)'], + }); + + expect(interpolation(0)).toBe('rgba(0, 100, 200, 0)'); + expect(interpolation(0.5)).toBe('rgba(25, 125, 225, 0.25)'); + expect(interpolation(1)).toBe('rgba(50, 150, 250, 0.5)'); + }); + + it('should work with negative and decimal values in string ranges', () => { + var interpolation = Interpolation.create({ + inputRange: [0, 1], + outputRange: ['-100.5deg', '100deg'], + }); + + expect(interpolation(0)).toBe('-100.5deg'); + expect(interpolation(0.5)).toBe('-0.25deg'); + expect(interpolation(1)).toBe('100deg'); + }); + + it('should crash when chaining an interpolation that returns a string', () => { + var interpolation = Interpolation.create({ + inputRange: [0, 1], + outputRange: [0, 1], + }); + expect(() => { interpolation('45rad'); }).toThrow(); + }); + + it('should crash when defining output range with different pattern', () => { + expect(() => Interpolation.create({ + inputRange: [0, 1], + outputRange: ['rgba(0, 100, 200, 0)', 'rgb(50, 150, 250)'], + })).toThrow(); + + expect(() => Interpolation.create({ + inputRange: [0, 1], + outputRange: ['20deg', '30rad'], + })).toThrow(); + }); +}); diff --git a/Libraries/Animation/Animated/package.json b/Libraries/Animation/Animated/package.json new file mode 100644 index 000000000..35cc7ccac --- /dev/null +++ b/Libraries/Animation/Animated/package.json @@ -0,0 +1,13 @@ +{ + "name": "react-animated", + "description": "Animated provides powerful mechanisms for animating your React views", + "version": "0.1.0", + "keywords": [ + "react", + "animated", + "animation" + ], + "license": "BSD-3-Clause", + "main": "Animated.js", + "readmeFilename": "README.md" +} diff --git a/Libraries/Animation/bezier.js b/Libraries/Animation/bezier.js new file mode 100644 index 000000000..11b02f501 --- /dev/null +++ b/Libraries/Animation/bezier.js @@ -0,0 +1,80 @@ +/** + * https://github.com/arian/cubic-bezier + * + * MIT License + * + * Copyright (c) 2013 Arian Stolwijk + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * @providesModule bezier + * @nolint + */ + +module.exports = function(x1, y1, x2, y2, epsilon){ + + var curveX = function(t){ + var v = 1 - t; + return 3 * v * v * t * x1 + 3 * v * t * t * x2 + t * t * t; + }; + + var curveY = function(t){ + var v = 1 - t; + return 3 * v * v * t * y1 + 3 * v * t * t * y2 + t * t * t; + }; + + var derivativeCurveX = function(t){ + var v = 1 - t; + return 3 * (2 * (t - 1) * t + v * v) * x1 + 3 * (- t * t * t + 2 * v * t) * x2; + }; + + return function(t){ + + var x = t, t0, t1, t2, x2, d2, i; + + // First try a few iterations of Newton's method -- normally very fast. + for (t2 = x, i = 0; i < 8; i++){ + x2 = curveX(t2) - x; + if (Math.abs(x2) < epsilon) return curveY(t2); + d2 = derivativeCurveX(t2); + if (Math.abs(d2) < 1e-6) break; + t2 = t2 - x2 / d2; + } + + t0 = 0, t1 = 1, t2 = x; + + if (t2 < t0) return curveY(t0); + if (t2 > t1) return curveY(t1); + + // Fallback to the bisection method for reliability. + while (t0 < t1){ + x2 = curveX(t2); + if (Math.abs(x2 - x) < epsilon) return curveY(t2); + if (x > x2) t0 = t2; + else t1 = t2; + t2 = (t1 - t0) * .5 + t0; + } + + // Failure + return curveY(t2); + + }; + +}; diff --git a/Libraries/react-native/react-native.js b/Libraries/react-native/react-native.js index 42cf30f51..c683ff95e 100644 --- a/Libraries/react-native/react-native.js +++ b/Libraries/react-native/react-native.js @@ -45,6 +45,7 @@ var ReactNative = Object.assign(Object.create(require('React')), { ActionSheetIOS: require('ActionSheetIOS'), AdSupportIOS: require('AdSupportIOS'), AlertIOS: require('AlertIOS'), + Animated: require('Animated'), AppRegistry: require('AppRegistry'), AppStateIOS: require('AppStateIOS'), AsyncStorage: require('AsyncStorage'),