├── webpack.config.js ├── demo ├── index.html ├── kinetic.html ├── kinetic.js ├── index.min.js └── main.js ├── litterate.config.js ├── LICENSE ├── .gitignore ├── package.json ├── docs ├── index.html ├── main.css └── src │ ├── spring.js.html │ └── index.js.html ├── src ├── spring.js └── index.js ├── .eslintrc.js └── README.md /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | entry: './src/index.js', 5 | mode: 'production', 6 | output: { 7 | path: path.resolve(__dirname, 'dist'), 8 | filename: 'index.min.js', 9 | }, 10 | } 11 | 12 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Animated-Value Demo 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /demo/kinetic.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Animated-Value.Kinetic Demo 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /litterate.config.js: -------------------------------------------------------------------------------- 1 | //> This is the litterate configuration file for litterate itself. 2 | module.exports = { 3 | name: 'Animated Value', 4 | description: '`animated-value` is an **imperative animation API for declarative UI renderers**, like React, Preact, and Torus. It allows us to build rich, fully interactive animations with the full benefits of a JavaScript-driven imperative animation system -- custom tweening and physics including spring-based interactive physics, full interruptibility and redirectability, reliable chaining and callbacks, and more -- within the robust declarative UI frameworks we use to build apps today. Read more [on GitHub](https://github.com/thesephist/codeframe).', 5 | //> We use GitHub Pages to host this generated site, which lives under a /animated-value subdirectory 6 | baseURL: '/animated-value', 7 | files: [ 8 | './src/**/*.js', 9 | ], 10 | } 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Linus Lee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Animated-Value 2 | dist/ 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (https://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules/ 40 | jspm_packages/ 41 | 42 | # TypeScript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | 63 | # next.js build output 64 | .next 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "animated-value", 3 | "version": "0.2.4", 4 | "description": "An imperative animation API for declarative UI renderers", 5 | "main": "src/index.js", 6 | "repository": "git@github.com:thesephist/animated-value.git", 7 | "author": "Linus Lee ", 8 | "license": "MIT", 9 | "scripts": { 10 | "build": "webpack --config webpack.config.js && cp dist/index.min.js demo/", 11 | "build:dev": "webpack --config webpack.config.js --watch", 12 | "test:size": "bundlesize", 13 | "prepublishOnly": "webpack --config webpack.config.js", 14 | "deploy:demo": "surge -p demo/ --domain https://animated-value.surge.sh", 15 | "docs": "litterate --config ./litterate.config.js", 16 | "lint": "eslint src/**/*.js" 17 | }, 18 | "files": [ 19 | "src", 20 | "dist" 21 | ], 22 | "dependencies": { 23 | "bezier-easing": "^2.1.0" 24 | }, 25 | "devDependencies": { 26 | "bundlesize": "^0.18.0", 27 | "eslint": "^6.8.0", 28 | "litterate": "^0.1.2", 29 | "webpack": "^4.42.0", 30 | "webpack-cli": "^3.3.11" 31 | }, 32 | "bundlesize": [ 33 | { 34 | "path": "dist/*.js", 35 | "threshold": "2.5kB" 36 | } 37 | ], 38 | "keywords": [ 39 | "animate", 40 | "animation", 41 | "easing", 42 | "bezier", 43 | "component", 44 | "transition", 45 | "torus", 46 | "react" 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Animated Value 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |

Animated Value

16 |

animated-value is an imperative animation API for declarative UI renderers, like React, Preact, and Torus. It allows us to build rich, fully interactive animations with the full benefits of a JavaScript-driven imperative animation system -- custom tweening and physics including spring-based interactive physics, full interruptibility and redirectability, reliable chaining and callbacks, and more -- within the robust declarative UI frameworks we use to build apps today. Read more on GitHub.

17 | 18 | 19 |
20 | 21 |

Annotated source files

22 | 23 | 24 |
25 |

26 |         
27 |
28 | 29 | 30 | -------------------------------------------------------------------------------- /docs/main.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | padding: 0; 5 | font-family: system-ui, 'Helvetica', 'Arial', sans-serif; 6 | } 7 | body { 8 | background: #f8f8f8; 9 | } 10 | main { 11 | display: flex; 12 | flex-direction: column; 13 | justify-content: flex-start; 14 | align-items: flex-start; 15 | margin: 0 auto; 16 | min-height: 100vh; 17 | position: relative; /* to size ::before to full height */ 18 | } 19 | main::before { 20 | content: ''; 21 | display: block; 22 | height: 100%; 23 | width: 440px; 24 | position: absolute; 25 | top: 0; 26 | left: 0; 27 | background: #fff; 28 | z-index: -1; 29 | } 30 | h1, 31 | h2, 32 | h3, 33 | h4, 34 | h5, 35 | h6 { 36 | font-weight: normal; 37 | line-height: 1.3em; 38 | } 39 | .line { 40 | display: flex; 41 | flex-direction: row; 42 | justify-content: flex-start; 43 | align-items: flex-end; 44 | font-size: 16px; 45 | } 46 | .line:first-child .doc { 47 | padding-top: 64px; 48 | } 49 | .line:last-child .doc { 50 | padding-bottom: 64px; 51 | height: 100%; 52 | } 53 | .line:last-child { 54 | align-items: flex-start; 55 | flex-grow: 1; 56 | } 57 | .doc { 58 | min-height: 24px; 59 | box-sizing: border-box; 60 | width: 440px; 61 | line-height: 1.5em; 62 | flex-shrink: 0; 63 | flex-grow: 0; 64 | padding-left: 32px; 65 | padding-right: 32px; 66 | } 67 | .doc p { 68 | margin: 0; 69 | } 70 | pre { 71 | margin: 0; 72 | margin-left: 8px; 73 | line-height: 1.5em; 74 | font-size: 14px; 75 | } 76 | code { 77 | font-size: .9em; 78 | background: #f8f8f8; 79 | box-sizing: border-box; 80 | padding: 2px 4px; 81 | border: 1px solid #aaa; 82 | } 83 | pre, 84 | code { 85 | font-family: 'Menlo', 'Monaco', monospace; 86 | overflow: hidden !important; /* override hljs's scroll style */ 87 | } 88 | .source .lineNumber { 89 | font-weight: normal; 90 | opacity: .2; 91 | margin-right: 18px; 92 | width: 36px; 93 | text-align: right; 94 | display: inline-block; 95 | } 96 | .hljs { 97 | padding: 0 !important; 98 | background: transparent !important; 99 | } 100 | .fade { 101 | opacity: .35; 102 | } 103 | a { 104 | opacity: .8; 105 | color: #777; 106 | display: inline-block; 107 | margin-bottom: 18px; 108 | } 109 | p a { 110 | display: inline; 111 | } 112 | a:hover { 113 | opacity: 1; 114 | } 115 | .doc .sourceLink { 116 | margin-top: 8px; 117 | } 118 | -------------------------------------------------------------------------------- /demo/kinetic.js: -------------------------------------------------------------------------------- 1 | const { 2 | StyledComponent, 3 | } = Torus; 4 | 5 | class DemoView extends StyledComponent { 6 | 7 | init() { 8 | this.xPosition = new AnimatedValue.Kinetic({ 9 | start: 0, 10 | stiffness: 3, 11 | damping: .6, 12 | }); 13 | this.yPosition = new AnimatedValue.Kinetic({ 14 | start: 0, 15 | stiffness: 3, 16 | damping: .6, 17 | }); 18 | 19 | this.handlePlayClick = this.handlePlayClick.bind(this); 20 | } 21 | 22 | styles() { 23 | return css` 24 | font-family: system-ui, sans-serif; 25 | max-width: 700px; 26 | margin: 24px auto; 27 | line-height: 1.5em; 28 | h1 { 29 | line-height: 1.5em; 30 | } 31 | .box { 32 | height: 100px; 33 | width: 100px; 34 | background: turquoise; 35 | box-shadow: 0 2px 6px -1px rgba(0, 0, 0, .3); 36 | border-radius: 4px; 37 | } 38 | button { 39 | padding: 4px 6px; 40 | font-size: 16px; 41 | border-radius: 4px; 42 | margin: 4px; 43 | background: #eee; 44 | cursor: pointer; 45 | } 46 | code { 47 | font-size: 1.3em; 48 | background: #eee; 49 | padding: 3px 6px; 50 | border-radius: 4px; 51 | } 52 | .playArea { 53 | width: 100%; 54 | height: 500px; 55 | background: #eee; 56 | position: relative; 57 | .box { 58 | position: absolute; 59 | top: 0; 60 | left: 0; 61 | } 62 | } 63 | `; 64 | } 65 | 66 | handlePlayClick(evt) { 67 | requestAnimationFrame(() => { 68 | requestAnimationFrame(() => { 69 | const rect = this.node.querySelector('.playArea').getBoundingClientRect(); 70 | this.xPosition.playTo(evt.clientX - rect.left - 50); 71 | this.yPosition.playTo(evt.clientY - rect.top - 50, () => this.render()).then(result => { 72 | console.log(`Animation resolved, returned ${result}`); 73 | }); 74 | }); 75 | }); 76 | } 77 | 78 | compose() { 79 | return jdom`
80 |

animated-value physics-based animations demo

81 | 82 |

This is a simple demo of the animated-value JavaScript library for rendering imperative animations in declarative UI frameworks. This demo is built with a UI framework called Torus, but you can also use the library with most other declarative, component-based UI frameworks with class components.

83 | 84 |

85 | In this demo, we're animating the X and Y positions of the box to where you click within the grey box, using the kinetic animated values capabilities of the library. 86 | You can read the source code behind this demo 87 | here. 88 |

89 | 90 |
91 |
94 |
95 |
`; 96 | } 97 | 98 | } 99 | 100 | const demoView = new DemoView(); 101 | document.body.appendChild(demoView.node); 102 | -------------------------------------------------------------------------------- /src/spring.js: -------------------------------------------------------------------------------- 1 | /* springFactory 2 | * modified from https://hackernoon.com/the-spring-factory-4c3d988e7129 3 | * 4 | * Generate a physically realistic easing curve for a damped mass-spring system. 5 | * 6 | * Required: 7 | * damping (zeta): [0, 1) 8 | * stiffness: 0...inf 9 | * 10 | * Optional: 11 | * initial_position: -1..1, default 1 12 | * initial_velocity: -inf..+inf, default 0 13 | * 14 | * Return: f(t), t in 0..1 15 | */ 16 | function springFactory({ 17 | damping = .8, 18 | stiffness = 3, 19 | initial_position = 1, 20 | initial_velocity = 0, 21 | } = {}) { 22 | const zeta = damping; 23 | const k = stiffness; 24 | const y0 = initial_position; 25 | const v0 = initial_velocity; 26 | 27 | const A = y0; 28 | let B; 29 | let omega; 30 | 31 | //> If v0 is 0, an analytical solution exists, otherwise, 32 | // we need to numerically solve it. 33 | if (Math.abs(v0) < 1e-6) { 34 | B = zeta * y0 / Math.sqrt(1 - (zeta * zeta)); 35 | omega = computeOmega(A, B, k, zeta); 36 | } else { 37 | const result = numericallySolveOmegaAndB({ 38 | zeta: zeta, 39 | k: k, 40 | y0: y0, 41 | // Modified from original to add factor PI/2 to keep velocity 42 | // self-consistent. 43 | v0: v0 / Math.PI / 2, 44 | }); 45 | 46 | B = result.B; 47 | omega = result.omega; 48 | } 49 | 50 | omega *= 2 * Math.PI; 51 | const omega_d = omega * Math.sqrt(1 - (zeta * zeta)); 52 | 53 | return t => { 54 | const sinusoid = (A * Math.cos(omega_d * t)) + (B * Math.sin(omega_d * t)); 55 | return Math.exp(-t * zeta * omega) * sinusoid; 56 | }; 57 | } 58 | 59 | function computeOmega(A, B, k, zeta) { 60 | 61 | //> Haven't quite figured out why yet, but to ensure same behavior of 62 | // k when argument of arctangent is negative, need to subtract pi 63 | // otherwise an extra halfcycle occurs. 64 | 65 | //> It has something to do with -atan(-x) = atan(x), 66 | // the range of atan being (-pi/2, pi/2) which is a difference of pi. 67 | 68 | //> The other way to look at it is that for every integer k there is a 69 | // solution and the 0 point for k is arbitrary, we just want it to be 70 | // equal to the thing that gives us the same number of halfcycles as k. 71 | if (A * B < 0 && k >= 1) { 72 | k --; 73 | } 74 | 75 | return (-Math.atan(A / B) + (Math.PI * k)) / (2 * Math.PI * Math.sqrt(1 - (zeta * zeta))); 76 | } 77 | 78 | 79 | //> Resolve recursive definition of omega an B using bisection method 80 | function numericallySolveOmegaAndB({ 81 | zeta, 82 | k, 83 | y0 = 1, 84 | v0 = 0, 85 | } = {}) { 86 | //> See [Underdamping on Wikipedia](https://en.wikipedia.org/wiki/Damping#Under-damping_.280_.E2.89.A4_.CE.B6_.3C_1.29). 87 | // B and omega are recursively defined in solution. Know omega in terms of B, will numerically 88 | // solve for B. 89 | function errorfn(B, omega) { 90 | const omega_d = omega * Math.sqrt(1 - (zeta * zeta)); 91 | return B - (((zeta * omega * y0) + v0) / omega_d); 92 | } 93 | 94 | //> Initial guess that's pretty close 95 | const A = y0; 96 | let B = zeta; 97 | 98 | let omega; 99 | let error; 100 | let direction; 101 | 102 | function step() { 103 | omega = computeOmega(A, B, k, zeta); 104 | error = errorfn(B, omega); 105 | direction = -Math.sign(error); 106 | } 107 | 108 | step(); 109 | 110 | const tolerence = 1e-6; 111 | let lower; 112 | let upper; 113 | 114 | let ct = 0; 115 | const maxct = 1e3; 116 | 117 | if (direction > 0) { 118 | while (direction > 0) { 119 | ct ++; 120 | 121 | if (ct > maxct) { 122 | break; 123 | } 124 | 125 | lower = B; 126 | 127 | B *= 2; 128 | step(); 129 | } 130 | 131 | upper = B; 132 | } else { 133 | upper = B; 134 | 135 | B *= -1; 136 | 137 | while (direction < 0) { 138 | ct ++; 139 | 140 | if (ct > maxct) { 141 | break; 142 | } 143 | 144 | lower = B; 145 | 146 | B *= 2; 147 | step(); 148 | } 149 | 150 | lower = B; 151 | } 152 | 153 | while (Math.abs(error) > tolerence) { 154 | ct ++; 155 | 156 | if (ct > maxct) { 157 | break; 158 | } 159 | 160 | B = (upper + lower) / 2; 161 | step(); 162 | 163 | if (direction > 0) { 164 | lower = B; 165 | } else { 166 | upper = B; 167 | } 168 | } 169 | 170 | return { 171 | omega, 172 | B, 173 | }; 174 | } 175 | 176 | //> Export a namespaced version of the spring curve solver. 177 | export {springFactory} 178 | -------------------------------------------------------------------------------- /demo/index.min.js: -------------------------------------------------------------------------------- 1 | !function(t){var e={};function s(i){if(e[i])return e[i].exports;var n=e[i]={i:i,l:!1,exports:{}};return t[i].call(n.exports,n,n.exports,s),n.l=!0,n.exports}s.m=t,s.c=e,s.d=function(t,e,i){s.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:i})},s.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},s.t=function(t,e){if(1&e&&(t=s(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var i=Object.create(null);if(s.r(i),Object.defineProperty(i,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var n in t)s.d(i,n,function(e){return t[e]}.bind(null,n));return i},s.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return s.d(e,"a",e),e},s.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},s.p="",s(s.s=1)}([function(t,e){var s="function"==typeof Float32Array;function i(t,e){return 1-3*e+3*t}function n(t,e){return 3*e-6*t}function r(t){return 3*t}function a(t,e,s){return((i(e,s)*t+n(e,s))*t+r(e))*t}function o(t,e,s){return 3*i(e,s)*t*t+2*n(e,s)*t+r(e)}function u(t){return t}t.exports=function(t,e,i,n){if(!(0<=t&&t<=1&&0<=i&&i<=1))throw new Error("bezier x values must be in [0, 1] range");if(t===e&&i===n)return u;for(var r=s?new Float32Array(11):new Array(11),l=0;l<11;++l)r[l]=a(.1*l,t,i);function h(e){for(var s=0,n=1;10!==n&&r[n]<=e;++n)s+=.1;--n;var u=s+.1*((e-r[n])/(r[n+1]-r[n])),l=o(u,t,i);return l>=.001?function(t,e,s,i){for(var n=0;n<4;++n){var r=o(e,s,i);if(0===r)return e;e-=(a(e,s,i)-t)/r}return e}(e,u,t,i):0===l?u:function(t,e,s,i,n){var r,o,u=0;do{(r=a(o=e+(s-e)/2,i,n)-t)>0?s=o:e=o}while(Math.abs(r)>1e-7&&++u<10);return o}(e,s,s+.1,t,i)}return function(t){return 0===t?0:1===t?1:a(h(t),e,n)}}},function(t,e,s){"use strict";s.r(e),s.d(e,"Kinetic",(function(){return m}));var i=s(0);function n({damping:t=.8,stiffness:e=3,initial_position:s=1,initial_velocity:i=0}={}){const n=t,a=e,o=s,u=i,l=o;let h,c;if(Math.abs(u)<1e-6)h=n*o/Math.sqrt(1-n*n),c=r(l,h,a,n);else{const t=function({zeta:t,k:e,y0:s=1,v0:i=0}={}){const n=s;let a,o,u,l=t;function h(){a=r(n,l,e,t),o=function(e,n){const r=n*Math.sqrt(1-t*t);return e-(t*n*s+i)/r}(l,a),u=-Math.sign(o)}h();let c,f,p=0;if(u>0){for(;u>0&&(p++,!(p>1e3));)c=l,l*=2,h();f=l}else{for(f=l,l*=-1;u<0&&(p++,!(p>1e3));)c=l,l*=2,h();c=l}for(;Math.abs(o)>1e-6&&(p++,!(p>1e3));)l=(f+c)/2,h(),u>0?c=l:f=l;return{omega:a,B:l}}({zeta:n,k:a,y0:o,v0:u/Math.PI/2});h=t.B,c=t.omega}c*=2*Math.PI;const f=c*Math.sqrt(1-n*n);return t=>{const e=l*Math.cos(f*t)+h*Math.sin(f*t);return Math.exp(-t*n*c)*e}}function r(t,e,s,i){return t*e<0&&s>=1&&s--,(-Math.atan(t/e)+Math.PI*s)/(2*Math.PI*Math.sqrt(1-i*i))}const a=t=>t,o=()=>Date.now(),u={LINEAR:a,EASE:i(.25,.1,.25,1),EASE_IN:i(.42,0,1,1),EASE_OUT:i(0,0,.58,1),EASE_IN_OUT:i(.42,0,.58,1),EASE_IN_BACK:i(.6,-.28,.735,.045),EASE_OUT_BACK:i(.175,.885,.32,1.275),EXPO_IN:i(.95,.05,.795,.035),EXPO_OUT:i(.19,1,.22,1),EXPO_IN_OUT:i(1,0,0,1)};let l=!1,h=[];const c=()=>{requestAnimationFrame(()=>{const t=h;h=[];for(const e of t)e();h.length>0?c():l=!1})};class f{constructor(){this.state=0,this._duration=null,this._startTime=null,this._pausedTime=null,this._callback=null,this._promise=Promise.resolve(),this._promiseResolver=null}play(t,e){return 1===this.state||(this.state=1,this._duration=t,this._startTime=o(),this._promise=new Promise((t,s)=>{this._promiseResolver=t,this._callback=()=>{void 0!==e&&e(),1===this.state&&(o()-this._startTime>this._duration?(this.pause(),null!==this._promiseResolver&&(this._promiseResolver(!0),this._promiseResolver=null)):(t=>{h.push(t),l||(l=!0,c())})(this._callback))},this._callback()})),this._promise}pause(){1===this.state&&(this.state=2,this._pausedTime=o()-this._startTime,this._startTime=null)}resume(){2===this.state&&(this.state=1,this._startTime=o()-this._pausedTime,this._pausedTime=null,this._callback())}reset(){null!==this._promiseResolver&&(this._promiseResolver(!1),this._promiseResolver=null),this.state=0,this._duration=null,this._startTime=null,this._pausedTime=null,this._callback=null}}class p extends f{constructor({start:t=0,end:e=1,ease:s=a}={}){super(),this.start=t,this.end=e,this.ease=Array.isArray(s)?i(...s):s,this._fillState=t}static get CURVES(){return u}static compose(...t){return new _(t)}static get Kinetic(){return d}set(t){this._fillState=t}value(){if(1!==this.state)return this._fillState;{const t=o()-this._startTime,e=t>this._duration?1:t/this._duration;return(this.end-this.start)*this.ease(e)+this.start}}pause(){1===this.state&&(this._fillState=this.value()),super.pause()}reset(){super.reset(),this._fillState=this.start}}class _ extends f{constructor(t){super(),this._playables=t;for(const t of this._playables)t instanceof d&&console.warn("AnimatedValue.Kinetic cannot be composed into composite animated values. Doing so may result in buggy and undefined behavior.")}play(t,e){const s=super.play(t,e);for(const e of this._playables)e.play(t);return s}pause(){super.pause();for(const t of this._playables)t.pause()}resume(){if(2===this.state)for(const t of this._playables)t.resume();super.resume()}reset(){super.reset();for(const t of this._playables)t.reset()}}class d extends p{constructor({start:t=0,end:e=null,stiffness:s=3,damping:i=.8,duration:r=1e3}={}){null===e&&(e=t);const a=n({damping:i,stiffness:s=~~s,initial_velocity:0});super({start:t,end:e,ease:t=>1-a(t)}),this.damping=i,this.stiffness=s,this._dynDuration=r}playTo(t,e){const s=o(),i=(s-this._startTime)/this._duration,r=(this.ease(i)-this.ease(i-1e-4))/1e-4*(this.end-this.start)/(t-this.value()),a=n({damping:this.damping,stiffness:this.stiffness,initial_velocity:-r});return this.start=this.value(),this.end=t,this.ease=t=>1-a(t),this._startTime=s,1!==this.state&&super.play(this._dynDuration,e),this._promise}play(){console.warn("Kinetic Animated Values should be played with playTo()")}reset(){console.warn("Kinetic Animated Values cannot be reset")}}"object"==typeof window&&(window.AnimatedValue=p);const m=d;e.default=p}]); -------------------------------------------------------------------------------- /demo/main.js: -------------------------------------------------------------------------------- 1 | const { 2 | StyledComponent, 3 | } = Torus; 4 | 5 | class DemoView extends StyledComponent { 6 | 7 | init() { 8 | this.opacity = new AnimatedValue({ 9 | start: .2, 10 | end: 1, 11 | }); 12 | this.xPosition = new AnimatedValue({ 13 | start: 0, 14 | end: 300, 15 | ease: AnimatedValue.CURVES.EASE_OUT_BACK, 16 | }); 17 | this.yPosition = new AnimatedValue({ 18 | start: 0, 19 | end: 200, 20 | ease: AnimatedValue.CURVES.EXPO_OUT, 21 | }); 22 | this.scale = new AnimatedValue({ 23 | start: 1, 24 | end: 1.8, 25 | ease: AnimatedValue.CURVES.EXPO_OUT, 26 | }); 27 | this.angle = new AnimatedValue({ 28 | start: 300, 29 | end: 0, 30 | ease: AnimatedValue.CURVES.EASE_OUT_BACK, 31 | }); 32 | this.slideOut = AnimatedValue.compose( 33 | this.opacity, 34 | this.xPosition, 35 | this.yPosition, 36 | this.scale, 37 | this.angle, 38 | ); 39 | 40 | this.handleStartClick = this.handleStartClick.bind(this); 41 | this.handleResetClick = this.handleResetClick.bind(this); 42 | this.handlePauseClick = this.handlePauseClick.bind(this); 43 | this.handleResumeClick = this.handleResumeClick.bind(this); 44 | } 45 | 46 | styles() { 47 | return { 48 | 'font-family': 'system-ui, sans-serif', 49 | 'max-width': '700px', 50 | 'margin': '24px auto', 51 | 'line-height': '1.5em', 52 | 'h1': { 53 | 'line-height': '1.5em', 54 | }, 55 | '.box': { 56 | 'height': '100px', 57 | 'width': '100px', 58 | 'background': 'turquoise', 59 | 'box-shadow': '0 2px 6px -1px rgba(0, 0, 0, .3)', 60 | 'border-radius': '4px', 61 | }, 62 | 'button': { 63 | 'padding': '4px 8px', 64 | 'font-size': '16px', 65 | 'border-radius': '4px', 66 | 'margin': '4px', 67 | 'background': '#eee', 68 | 'cursor': 'pointer', 69 | }, 70 | 'code': { 71 | 'font-size': '1.3em', 72 | 'background': '#eee', 73 | 'padding': '3px 6px', 74 | 'border-radius': '4px', 75 | }, 76 | } 77 | } 78 | 79 | handleStartClick() { 80 | this.slideOut.play(1200, () => this.render()).then(result => { 81 | console.log('Animation resolved to:', result); 82 | }); 83 | } 84 | 85 | handleResetClick() { 86 | this.slideOut.reset(); 87 | this.render(); 88 | } 89 | 90 | handlePauseClick() { 91 | this.slideOut.pause(); 92 | } 93 | 94 | handleResumeClick() { 95 | this.slideOut.resume(); 96 | } 97 | 98 | compose() { 99 | return jdom`
100 |

animated-value demo

101 | 102 |

This is a simple demo of the animated-value JavaScript library for rendering imperative animations in declarative UI frameworks. This demo is built with a UI framework called Torus, but you can also use the library with most other declarative, component-based UI frameworks with class components.

103 | 104 |

In this demo, we're animating five different properties -- opacity, x and y translations, scale, and rotation -- together as a single animation with animated-value. You can grab the code for this demo here.

105 | 106 |

You can grab the npm package with npm install animated-value and read more at the link above.

107 | 108 | 111 | 114 | 117 | 120 |
125 |
`; 126 | } 127 | 128 | } 129 | 130 | class DemoKineticView extends StyledComponent { 131 | 132 | init() { 133 | this._dest = 0; 134 | 135 | this.xPosition = new AnimatedValue.Kinetic({ 136 | start: 0, 137 | stiffness: 6, 138 | damping: .2, 139 | }); 140 | 141 | this.handleStartClick = this.handleStartClick.bind(this); 142 | this.handlePauseClick = this.handlePauseClick.bind(this); 143 | this.handleResumeClick = this.handleResumeClick.bind(this); 144 | } 145 | 146 | styles() { 147 | return { 148 | 'font-family': 'system-ui, sans-serif', 149 | 'max-width': '700px', 150 | 'margin': '24px auto', 151 | 'line-height': '1.5em', 152 | '.box': { 153 | 'height': '100px', 154 | 'width': '100px', 155 | 'background': 'turquoise', 156 | 'box-shadow': '0 2px 6px -1px rgba(0, 0, 0, .3)', 157 | 'border-radius': '4px', 158 | }, 159 | 'button': { 160 | 'padding': '4px 8px', 161 | 'font-size': '16px', 162 | 'border-radius': '4px', 163 | 'margin': '4px', 164 | 'background': '#eee', 165 | 'cursor': 'pointer', 166 | }, 167 | 'code': { 168 | 'font-size': '1.3em', 169 | 'background': '#eee', 170 | 'padding': '3px 6px', 171 | 'border-radius': '4px', 172 | }, 173 | } 174 | } 175 | 176 | handleStartClick() { 177 | this._dest = this._dest === 0 ? 300 : 0; 178 | this.xPosition.playTo(this._dest, () => this.render()).then(result => { 179 | console.log('Animation resolved to:', result); 180 | }); 181 | } 182 | 183 | handlePauseClick() { 184 | this.xPosition.pause(); 185 | } 186 | 187 | handleResumeClick() { 188 | this.xPosition.resume(); 189 | } 190 | 191 | compose() { 192 | return jdom`
193 |

In this demo, we're showing off the physics-based, spring-animation capabilities of animated-value. Kinetic animated values like this can be defined and controlled interactively and imperatively as well.

194 | 195 |

You can also check out a more complex and completely interactive demo of physics-based animation in this demo page.

196 | 197 | 200 | 203 | 206 |
209 |
`; 210 | } 211 | 212 | } 213 | 214 | const demoView = new DemoView(); 215 | const demoKineticView = new DemoKineticView(); 216 | document.body.appendChild(demoView.node); 217 | document.body.appendChild(demoKineticView.node); 218 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'env': { 3 | 'browser': true, 4 | 'commonjs': true, 5 | 'es6': true 6 | }, 7 | 'extends': 'eslint:recommended', 8 | 'parserOptions': { 9 | 'ecmaVersion': 2018, 10 | 'sourceType': 'module' 11 | }, 12 | 'rules': { 13 | 'accessor-pairs': 'error', 14 | 'array-bracket-newline': [ 15 | 'error', 16 | 'consistent', 17 | ], 18 | 'array-bracket-spacing': [ 19 | 'error', 20 | 'never' 21 | ], 22 | 'array-callback-return': 'error', 23 | 'array-element-newline': 'off', 24 | 'arrow-body-style': 'off', 25 | 'arrow-parens': [ 26 | 'error', 27 | 'as-needed' 28 | ], 29 | 'arrow-spacing': [ 30 | 'error', 31 | { 32 | 'after': true, 33 | 'before': true 34 | } 35 | ], 36 | 'block-scoped-var': 'error', 37 | 'block-spacing': 'error', 38 | 'brace-style': [ 39 | 'error', 40 | '1tbs' 41 | ], 42 | 'callback-return': 'off', 43 | 'camelcase': 'off', 44 | 'capitalized-comments': 'off', 45 | 'class-methods-use-this': 'off', 46 | 'comma-dangle': 'off', 47 | 'comma-spacing': [ 48 | 'error', 49 | { 50 | 'after': true, 51 | 'before': false 52 | } 53 | ], 54 | 'comma-style': [ 55 | 'error', 56 | 'last' 57 | ], 58 | 'comma-dangle': ['error', 'always-multiline'], 59 | 'complexity': 'off', 60 | 'computed-property-spacing': [ 61 | 'error', 62 | 'never' 63 | ], 64 | 'consistent-return': 'error', 65 | 'consistent-this': 'error', 66 | 'curly': [ 67 | 'error', 68 | 'all', 69 | ], 70 | 'default-case': 'off', 71 | 'dot-location': [ 72 | 'error', 73 | 'property' 74 | ], 75 | 'dot-notation': [ 76 | 'error', 77 | { 78 | 'allowKeywords': true 79 | } 80 | ], 81 | 'eol-last': [ 82 | 'error', 83 | 'always', 84 | ], 85 | 'eqeqeq': [ 86 | 'error', 87 | 'always', 88 | ], 89 | 'func-call-spacing': 'error', 90 | 'func-name-matching': 'error', 91 | 'func-names': 'off', 92 | 'func-style': [ 93 | 'error', 94 | 'declaration', 95 | { 96 | 'allowArrowFunctions': true 97 | } 98 | ], 99 | 'function-paren-newline': 'off', 100 | 'generator-star-spacing': 'error', 101 | 'global-require': 'error', 102 | 'guard-for-in': 'off', 103 | 'handle-callback-err': 'error', 104 | 'id-blacklist': 'error', 105 | 'id-length': 'off', 106 | 'id-match': 'error', 107 | 'implicit-arrow-linebreak': [ 108 | 'error', 109 | 'beside' 110 | ], 111 | 'indent': [ 112 | 'error', 113 | 4, 114 | { 115 | 'SwitchCase': 1, 116 | // When expression are indented in multiline templates, 117 | // we don't necessary want them indented to the template start. 118 | 'ignoredNodes': ['TemplateLiteral *'], 119 | } 120 | ], 121 | 'indent-legacy': 'off', 122 | 'init-declarations': 'off', 123 | 'jsx-quotes': 'error', 124 | 'key-spacing': 'error', 125 | 'keyword-spacing': [ 126 | 'error', 127 | { 128 | 'after': true, 129 | 'before': true 130 | } 131 | ], 132 | 'line-comment-position': 'off', 133 | 'linebreak-style': [ 134 | 'error', 135 | 'unix' 136 | ], 137 | 'lines-around-comment': 'error', 138 | 'lines-around-directive': 'error', 139 | 'lines-between-class-members': 'off', 140 | 'max-classes-per-file': 'off', 141 | 'max-depth': 'off', 142 | 'max-len': 'off', 143 | 'max-lines': 'off', 144 | 'max-lines-per-function': 'off', 145 | 'max-nested-callbacks': 'error', 146 | 'max-params': 'off', 147 | 'max-statements': 'off', 148 | 'max-statements-per-line': 'error', 149 | 'multiline-comment-style': 'off', 150 | 'new-parens': 'error', 151 | 'newline-after-var': 'off', 152 | 'newline-before-return': 'off', 153 | 'newline-per-chained-call': 'off', 154 | 'no-alert': 'off', 155 | 'no-array-constructor': 'error', 156 | 'no-async-promise-executor': 'error', 157 | 'no-await-in-loop': 'error', 158 | 'no-bitwise': 'off', 159 | 'no-buffer-constructor': 'error', 160 | 'no-caller': 'error', 161 | 'no-catch-shadow': 'error', 162 | 'no-confusing-arrow': 'off', 163 | 'no-console': 'off', 164 | 'no-continue': 'error', 165 | 'no-div-regex': 'error', 166 | 'no-duplicate-imports': 'error', 167 | 'no-else-return': 'off', 168 | 'no-empty-function': 'off', 169 | 'no-eq-null': 'error', 170 | 'no-eval': 'error', 171 | 'no-extend-native': 'error', 172 | 'no-extra-bind': 'error', 173 | 'no-extra-label': 'error', 174 | 'no-extra-parens': 'off', 175 | 'no-floating-decimal': 'off', 176 | 'no-global-assign': 'error', 177 | 'no-implicit-globals': 'error', 178 | 'no-implied-eval': 'error', 179 | 'no-inline-comments': 'off', 180 | 'no-invalid-this': 'error', 181 | 'no-iterator': 'error', 182 | 'no-label-var': 'error', 183 | 'no-labels': 'error', 184 | 'no-lone-blocks': 'error', 185 | 'no-lonely-if': 'off', 186 | 'no-loop-func': 'error', 187 | 'no-magic-numbers': 'off', 188 | 'no-misleading-character-class': 'error', 189 | 'no-mixed-operators': 'error', 190 | 'no-mixed-requires': 'error', 191 | 'no-multi-assign': 'off', 192 | 'no-multi-spaces': 'off', 193 | 'no-multi-str': 'error', 194 | 'no-multiple-empty-lines': 'error', 195 | 'no-native-reassign': 'error', 196 | 'no-negated-condition': 'off', 197 | 'no-negated-in-lhs': 'error', 198 | 'no-nested-ternary': 'error', 199 | 'no-new': 'error', 200 | 'no-new-func': 'off', 201 | 'no-new-object': 'error', 202 | 'no-new-require': 'error', 203 | 'no-new-wrappers': 'error', 204 | 'no-octal-escape': 'error', 205 | 'no-param-reassign': 'off', 206 | 'no-path-concat': 'error', 207 | 'no-plusplus': 'off', 208 | 'no-process-env': 'off', // for env vars 209 | 'no-process-exit': 'error', 210 | 'no-proto': 'error', 211 | 'no-prototype-builtins': 'error', 212 | 'no-restricted-globals': 'error', 213 | 'no-restricted-imports': 'error', 214 | 'no-restricted-modules': 'error', 215 | 'no-restricted-properties': 'error', 216 | 'no-restricted-syntax': 'error', 217 | 'no-return-assign': 'off', 218 | 'no-return-await': 'error', 219 | 'no-script-url': 'error', 220 | 'no-self-compare': 'error', 221 | 'no-sequences': 'off', 222 | 'no-shadow': 'off', 223 | 'no-shadow-restricted-names': 'error', 224 | 'no-spaced-func': 'error', 225 | 'no-sync': 'off', 226 | 'no-tabs': 'error', 227 | 'no-template-curly-in-string': 'error', 228 | 'no-ternary': 'off', 229 | 'no-throw-literal': 'error', 230 | 'no-trailing-spaces': 'error', 231 | 'no-undef': 'off', 232 | 'no-undef-init': 'error', 233 | 'no-undefined': 'off', 234 | 'no-underscore-dangle': 'off', 235 | 'no-unmodified-loop-condition': 'off', 236 | 'no-unneeded-ternary': 'error', 237 | 'no-unused-vars': [ 238 | 'error', 239 | { 240 | 'args': 'after-used', 241 | 'argsIgnorePattern': '^_', 242 | 'varsIgnorePattern': '^_', 243 | 'ignoreRestSiblings': true, 244 | }, 245 | ], 246 | 'no-unused-expressions': 'error', 247 | 'no-use-before-define': 'off', 248 | 'no-useless-call': 'error', 249 | 'no-useless-catch': 'error', 250 | 'no-useless-computed-key': 'error', 251 | 'no-useless-concat': 'error', 252 | 'no-useless-constructor': 'error', 253 | 'no-useless-rename': 'error', 254 | 'no-useless-return': 'off', 255 | 'no-var': 'error', 256 | 'no-void': 'error', 257 | 'no-warning-comments': 'error', 258 | 'no-whitespace-before-property': 'error', 259 | 'no-with': 'error', 260 | 'nonblock-statement-body-position': 'error', 261 | 'object-curly-newline': 'error', 262 | 'object-curly-spacing': [ 263 | 'error', 264 | 'never' 265 | ], 266 | 'object-shorthand': 'off', 267 | 'one-var': [ 268 | 'error', 269 | 'never', 270 | ], 271 | 'one-var-declaration-per-line': 'error', 272 | 'operator-assignment': [ 273 | 'error', 274 | 'always' 275 | ], 276 | 'operator-linebreak': [ 277 | 'error', 278 | 'before' 279 | ], 280 | 'padded-blocks': 'off', 281 | 'padding-line-between-statements': 'error', 282 | 'prefer-arrow-callback': 'error', 283 | 'prefer-const': [ 284 | 'error', 285 | { 286 | 'destructuring': 'any', 287 | } 288 | ], 289 | 'prefer-destructuring': 'off', 290 | 'prefer-numeric-literals': 'error', 291 | 'prefer-object-spread': 'off', 292 | 'prefer-promise-reject-errors': 'error', 293 | 'prefer-reflect': 'off', 294 | 'prefer-rest-params': 'error', 295 | 'prefer-spread': 'error', 296 | 'prefer-template': 'off', 297 | 'quote-props': 'off', 298 | 'quotes': [ 299 | 'error', 300 | 'single', 301 | { 302 | 'avoidEscape': true, 303 | }, 304 | ], 305 | 'radix': 'error', 306 | 'require-atomic-updates': 'error', 307 | 'require-await': 'error', 308 | 'require-jsdoc': 'off', 309 | 'require-unicode-regexp': 'off', 310 | 'rest-spread-spacing': [ 311 | 'error', 312 | 'never' 313 | ], 314 | // eslint doesn't have an easy option for 'always, 315 | // except after curlybraces' 316 | 'semi': 'off', 317 | 'semi-spacing': [ 318 | 'error', 319 | { 320 | 'after': true, 321 | 'before': false 322 | } 323 | ], 324 | 'semi-style': [ 325 | 'error', 326 | 'last' 327 | ], 328 | 'sort-imports': 'error', 329 | 'sort-keys': 'off', 330 | 'sort-vars': 'off', 331 | 'space-before-blocks': 'error', 332 | 'space-before-function-paren': 'off', 333 | 'space-in-parens': [ 334 | 'error', 335 | 'never' 336 | ], 337 | 'space-infix-ops': 'error', 338 | 'space-unary-ops': [ 339 | 'error', 340 | { 341 | 'words': true, 342 | 'nonwords': false, 343 | 'overrides': { 344 | '++': true, 345 | '--': true, 346 | }, 347 | }, 348 | ], 349 | 'spaced-comment': [ 350 | 'error', 351 | 'always', 352 | { 353 | // for litterate docs 354 | 'markers': ['>'], 355 | } 356 | ], 357 | 'strict': [ 358 | 'error', 359 | 'never' 360 | ], 361 | 'switch-colon-spacing': 'error', 362 | 'symbol-description': 'error', 363 | 'template-curly-spacing': [ 364 | 'error', 365 | 'never' 366 | ], 367 | 'template-tag-spacing': 'error', 368 | 'unicode-bom': [ 369 | 'error', 370 | 'never' 371 | ], 372 | 'valid-jsdoc': 'error', 373 | 'valid-typeof': 'error', 374 | 'vars-on-top': 'error', 375 | 'wrap-iife': 'error', 376 | 'wrap-regex': 'error', 377 | 'yield-star-spacing': 'error', 378 | 'yoda': [ 379 | 'error', 380 | 'never' 381 | ] 382 | } 383 | }; 384 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | //> Animation curves are usually defined as cubic Bezier 2 | // curves, but it turns out computing these functions on the fly 3 | // from the time domain is pretty tricky. We depend on this ~500B 4 | // library to resolve Bezier curves for us. 5 | import * as Bezier from 'bezier-easing'; 6 | 7 | //> Spring physics-based easing functions comes from this external 8 | // dependency that resolves spring physics curves into functions. 9 | import {springFactory} from './spring.js'; 10 | 11 | //> Linear easing curve 12 | const identity = t => t; 13 | 14 | //> Animation involves lots of measuring time. 15 | // This is a shortcut to get the current unix epoch time 16 | // in milliseconds. 17 | const now = () => Date.now(); 18 | 19 | //> AnimatedValue's animation objects are state machines, with 20 | // three states. These three states are represented as these 21 | // constants. 22 | const STATE_UNSTARTED = 0; 23 | const STATE_PLAYING = 1; 24 | const STATE_PAUSED = 2; 25 | 26 | //> By default, `AnimatedValue.CURVES` provides a useful set of 27 | // easing curves we can use out of the box. 28 | const CURVES = { 29 | LINEAR: identity, 30 | EASE: Bezier(0.25, 0.1, 0.25, 1), 31 | EASE_IN: Bezier(0.42, 0, 1, 1), 32 | EASE_OUT: Bezier(0, 0, 0.58, 1), 33 | EASE_IN_OUT: Bezier(0.42, 0, 0.58, 1), 34 | EASE_IN_BACK: Bezier(0.6, -0.28, 0.735, 0.045), 35 | EASE_OUT_BACK: Bezier(0.175, 0.885, 0.32, 1.275), 36 | EXPO_IN: Bezier(0.95, 0.05, 0.795, 0.035), 37 | EXPO_OUT: Bezier(0.19, 1, 0.22, 1), 38 | EXPO_IN_OUT: Bezier(1, 0, 0, 1), 39 | } 40 | 41 | //> ## Unified frame loop 42 | 43 | //> This section implements a unified frame loop for all animations 44 | // performed by animated-value. Rather than each animated value orchestrating 45 | // its own animation frame loop, if we implement one frame loop for the whole 46 | // library and hook into it from each animated value, we can save many unnecessary 47 | // calls to and from `requestAnimationFrame` during animation and keep code efficient. 48 | 49 | //> Is the unified frame loop (UFL) currently running? 50 | let rafRunning = false; 51 | //> Queue of callbacks to be run on the next animation frame 52 | let rafQueue = []; 53 | //> The function that runs at most once every animation frame. This calls 54 | // all queued callbacks once, and if necessary, enqueues a recursive call in the 55 | // next frame. 56 | const runAnimationFrame = () => { 57 | requestAnimationFrame(() => { 58 | const q = rafQueue; 59 | rafQueue = []; 60 | for (const cb of q) { 61 | cb(); 62 | } 63 | if (rafQueue.length > 0) { 64 | runAnimationFrame(); 65 | } else { 66 | rafRunning = false; 67 | } 68 | }); 69 | } 70 | //> The animation frame callback that enqueues new callbacks into the next 71 | // frame callback and optionally starts the frame loop if one is not running. 72 | const raf = callback => { 73 | rafQueue.push(callback); 74 | 75 | if (!rafRunning) { 76 | rafRunning = true; 77 | runAnimationFrame(); 78 | } 79 | } 80 | 81 | //> ## `Playable` interface 82 | 83 | //> The `Playable` class represents something whose timeline 84 | // can be played, paused, and reset. Both a single AnimatedValue, 85 | // as well as `CompositeAnimatedValue` (a combination of more than 86 | // one AV) inherit from `Playable`. This class enables us to have 87 | // polymorphic, imperative animation control APIs. 88 | class Playable { 89 | 90 | constructor() { 91 | //> A `Playable` is a state machine. 92 | this.state = STATE_UNSTARTED; 93 | //> The duration requested for an animation play-through 94 | this._duration = null; 95 | //> When did our current animation run start? `null` if the 96 | // animation is not currently playing. 97 | this._startTime = null; 98 | //> If we are paused, we keep track of how far through the 99 | // animation we were here. 100 | this._pausedTime = null; 101 | //> Callback after each frame is rendered during play 102 | this._callback = null; 103 | //> A promise that resolves when the currently playing animation 104 | // either finishes (resolves to `true`) or is reset (resolves to `false`). 105 | this._promise = Promise.resolve(); 106 | //> Temporary variable we use as a pointer to the resolver of `this._promise`, 107 | // so we can resolve the promise outside of the promise body. 108 | this._promiseResolver = null; 109 | } 110 | 111 | play(duration, callback) { 112 | if (this.state === STATE_PLAYING) { 113 | return this._promise; 114 | } 115 | 116 | this.state = STATE_PLAYING; 117 | this._duration = duration; 118 | this._startTime = now(); 119 | 120 | this._promise = new Promise((res, _rej) => { 121 | this._promiseResolver = res; 122 | this._callback = () => { 123 | if (callback !== undefined) { 124 | callback(); 125 | } 126 | //> Sometimes, in between frames, the user 127 | // will pause the animation but we assume it isn't paused 128 | // so we keep playing. This checks if we're supposed to 129 | // still be playing the next frame. 130 | if (this.state === STATE_PLAYING) { 131 | if (now() - this._startTime > this._duration) { 132 | this.pause(); 133 | if (this._promiseResolver !== null) { 134 | this._promiseResolver(true); 135 | this._promiseResolver = null; 136 | } 137 | } else { 138 | raf(this._callback); 139 | } 140 | } 141 | } 142 | this._callback(); 143 | }); 144 | return this._promise; 145 | } 146 | 147 | pause() { 148 | if (this.state === STATE_PLAYING) { 149 | this.state = STATE_PAUSED; 150 | this._pausedTime = now() - this._startTime; 151 | this._startTime = null; 152 | } 153 | } 154 | 155 | resume() { 156 | if (this.state === STATE_PAUSED) { 157 | this.state = STATE_PLAYING; 158 | this._startTime = now() - this._pausedTime; 159 | this._pausedTime = null; 160 | this._callback(); 161 | } 162 | } 163 | 164 | reset() { 165 | if (this._promiseResolver !== null) { 166 | this._promiseResolver(false); 167 | this._promiseResolver = null; 168 | } 169 | this.state = STATE_UNSTARTED; 170 | this._duration = null; 171 | this._startTime = null; 172 | this._pausedTime = null; 173 | this._callback = null; 174 | } 175 | 176 | } 177 | 178 | //> `AnimatedValue` represents a single value (number) that can 179 | // be animated with a duration and an easing curve. Most often, 180 | // an `AnimatedValue` corresponds to a single CSS property that 181 | // we're animating on a component, like translate / scale / opacity. 182 | class AnimatedValue extends Playable { 183 | 184 | constructor({ 185 | start = 0, 186 | end = 1, 187 | ease = identity, 188 | } = {}) { 189 | super(); 190 | this.start = start; 191 | this.end = end; 192 | //> We accept an array of cubic Bezier points as an easing function, 193 | // in which case we create a Bezier curve out of them. 194 | this.ease = Array.isArray(ease) ? Bezier(...ease) : ease; 195 | //> The fill state represents the value of the animated value whenever 196 | // the animation is not running. The value is initialized to the start position. 197 | this._fillState = start; 198 | } 199 | 200 | //> Statically defined so consumers of the API can define easing curves 201 | // as `AnimatedValue.CURVES.[CURVE_NAME]`. 202 | static get CURVES() { 203 | return CURVES; 204 | } 205 | 206 | //> Statically defined constructor for a composite animation, which takes 207 | // multiple `Playable`s (either single or composite animated values) and 208 | // plays all of them concurrently. 209 | static compose(...playables) { 210 | return new CompositeAnimatedValue(playables); 211 | } 212 | 213 | static get Kinetic() { 214 | return KineticValue; 215 | } 216 | 217 | set(value) { 218 | this._fillState = value; 219 | } 220 | 221 | //> What's the current numerical value of this animated value? 222 | // This API is intentionally not implemented as a getter, to communicate 223 | // to the API consumer that value computation has a nonzero cost with each access. 224 | value() { 225 | if (this.state !== STATE_PLAYING) { 226 | //> If the animation is not playing, just return the last fill value 227 | return this._fillState; 228 | } else { 229 | const elapsedTime = now() - this._startTime; 230 | const elapsedDuration = elapsedTime > this._duration ? 1 : elapsedTime / this._duration; 231 | return ((this.end - this.start) * this.ease(elapsedDuration)) + this.start; 232 | } 233 | } 234 | 235 | pause() { 236 | if (this.state === STATE_PLAYING) { 237 | //> The order of `super` call matters here, because we can't get the value if we aren't playing 238 | this._fillState = this.value(); 239 | } 240 | super.pause(); 241 | } 242 | 243 | reset() { 244 | super.reset(); 245 | this._fillState = this.start; 246 | } 247 | 248 | } 249 | 250 | //> A `CompositeAnimatedValue` is a `Playable` wrapper around many (single, composite) animated values, 251 | // that can run all of the animations in the same duration, concurrently. `CompositeAnimatedValue` 252 | // is polymorphic, and can take any `Playable` as a sub-animation. 253 | class CompositeAnimatedValue extends Playable { 254 | 255 | constructor(playables) { 256 | super(); 257 | this._playables = playables; 258 | for (const p of this._playables) { 259 | if (p instanceof KineticValue) { 260 | console.warn('AnimatedValue.Kinetic cannot be composed into composite animated values. Doing so may result in buggy and undefined behavior.'); 261 | } 262 | } 263 | } 264 | 265 | play(duration, callback) { 266 | //> `CompositeAnimatedValue#play()` swallows the callback here and calls it once 267 | // for the entire composition, efficiently, so the callback isn't called N 268 | // times for N animated values below this composite animation. 269 | const ret = super.play(duration, callback); 270 | for (const av of this._playables) { 271 | av.play(duration); 272 | } 273 | return ret; 274 | } 275 | 276 | pause() { 277 | super.pause(); 278 | for (const av of this._playables) { 279 | av.pause(); 280 | } 281 | } 282 | 283 | resume() { 284 | if (this.state === STATE_PAUSED) { 285 | for (const av of this._playables) { 286 | av.resume(); 287 | } 288 | } 289 | //> Order of `super.resume()` call is important -- if we resume first, the checks above don't work 290 | super.resume(); 291 | } 292 | 293 | reset() { 294 | super.reset(); 295 | for (const av of this._playables) { 296 | av.reset(); 297 | } 298 | } 299 | 300 | } 301 | 302 | //> A `KineticValue` or `AnimatedValue.Kinetic` is an animated value whose animations are defined by 303 | // spring physics. As such, it takes only a starting position and some phsyics constants, and are 304 | // aniamted to destination coordinates. Kinetic values also cannot be reset. 305 | class KineticValue extends AnimatedValue { 306 | 307 | constructor({ 308 | start = 0, 309 | end = null, 310 | stiffness = 3, 311 | damping = .8, 312 | duration = 1000, 313 | } = {}) { 314 | if (end === null) { 315 | end = start; 316 | } 317 | stiffness = ~~stiffness; 318 | 319 | const ease = springFactory({ 320 | damping, 321 | stiffness, 322 | initial_velocity: 0, 323 | }); 324 | super({ 325 | start, 326 | end, 327 | //> Functions from `springFactory` start at 1 and go to 0, so 328 | // we need to invert it for our use case. 329 | ease: t => 1 - ease(t), 330 | }); 331 | this.damping = damping; 332 | this.stiffness = stiffness; 333 | //> In kinetic physics-based animations, the animation duration is a parameter 334 | // over the whole spring, not a single animation. So we set it for the value itself 335 | // and store it here to use it in every animation instance. 336 | this._dynDuration = duration; 337 | } 338 | 339 | //> `playTo()` substitutes `play()` for kinetic values, and is the way to animate the 340 | // spring animated value to a new value. 341 | playTo(end, callback) { 342 | const n = now(); 343 | //> Get elapsed time scaled to the range [0, 1] 344 | const elapsed = (n - this._startTime) / this._duration; 345 | 346 | //> Determine instantaneous velocity 347 | const DIFF = 0.0001; 348 | const velDiff = (this.ease(elapsed) - this.ease(elapsed - DIFF)) / DIFF; 349 | 350 | //> Scale the velocity to the new start and end coordinates, since the distance 351 | // covered will modify how the [0, 1] range scales out to real values. 352 | const scaledVel = velDiff * (this.end - this.start) / (end - this.value()); 353 | 354 | //> Create a new easing curve based on the new velocity 355 | const ease = springFactory({ 356 | damping: this.damping, 357 | stiffness: this.stiffness, 358 | initial_velocity: -scaledVel, 359 | }); 360 | //> Reset animation values so the next frame will render using the new animation parameters 361 | this.start = this.value(); 362 | this.end = end; 363 | this.ease = t => 1 - ease(t); 364 | this._startTime = n; 365 | 366 | //> If there is not already an animation running, start it. 367 | if (this.state !== STATE_PLAYING) { 368 | super.play(this._dynDuration, callback); 369 | } 370 | //> Return promise for chaining calls. 371 | return this._promise; 372 | } 373 | 374 | //> Warnings for APIs that do not apply to kinetic values 375 | play() { 376 | console.warn('Kinetic Animated Values should be played with playTo()'); 377 | } 378 | 379 | reset() { 380 | console.warn('Kinetic Animated Values cannot be reset'); 381 | } 382 | 383 | } 384 | 385 | if (typeof window === 'object') { 386 | window.AnimatedValue = AnimatedValue; 387 | } 388 | 389 | // exported names: allow: 390 | // import AnimatedValue, { Kinetic } from 'animated-value'; 391 | export const Kinetic = KineticValue; 392 | export default AnimatedValue; 393 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Animated Value 2 | 3 | [![npm animated-value](https://img.shields.io/npm/v/animated-value.svg)](http://npm.im/animated-value) 4 | [![gzip size](http://img.badgesize.io/https://unpkg.com/animated-value/dist/index.min.js?compression=gzip)](https://unpkg.com/animated-value/dist/index.min.js) 5 | [![install size](https://packagephobia.now.sh/badge?p=animated-value)](https://packagephobia.now.sh/result?p=animated-value) 6 | 7 | `animated-value` is an **imperative animation API for declarative UI renderers**, like React, Preact, and [Torus](https://github.com/thesephist/torus). It allows you to build rich, fully interactive animations with the full benefits of a JavaScript-driven imperative animation system -- custom tweening and spring physics, reliable chaining and callbacks, and more -- within the robust declarative UI frameworks we use to build apps today. 8 | 9 | Recently, I discovered that Animated Value inexplicably shares some API similarities with React Native's excellent `Animated` APIs. Many concepts run parallel, and some don't carry over. 10 | 11 | Animations built with Animated Value can be ... 12 | 13 | - *Fully customized* with custom curves, tweening, and timing 14 | - *Interactive*, starting and stopping using imperative JavaScript APIs 15 | - *Interruptible*, meaning the UI can stop and move on from an animation at any point in time 16 | - *Fully redirectable*, where an animation can be re-defined to a different destination point or value, and the animation will transition smoothly from its current curve to the new curve, based on spring physics. 17 | - Synchronized with async and Promise-based timing APIs. 18 | 19 | You can see a simple demo of `animated-value` [here](https://animated-value.surge.sh); the source is linked, and also found under `demo/`. 20 | 21 | ## Imperative animation what? 22 | 23 | It's 2019! We build a lot of our user interfaces with web technologies, and most of us use _declarative_ rendering frameworks like **React** to define our user interfaces as a function of state. But this makes imperative animation -- being able to tell the UI "Here's where you start, and I'm going to tell you how to move through the animation" -- hairy. 24 | 25 | In declarative UI frameworks, it's also tricky to implement animations that we can start, stop, pause, and rewind programmatically based on timers or events -- CSS transitions sometimes fall short. 26 | 27 | Enter `animated-value`! 28 | 29 | ## Using `animated-value` 30 | 31 | You can import `animated-value` with a script tag... 32 | 33 | ```html 34 | 35 | ``` 36 | 37 | ... and you'll find the `AnimatedValue` object in the global scope (as `window.AnimatedValue`). 38 | 39 | Alternatively, you can install `animated-value` using npm... 40 | 41 | ```sh 42 | npm install animated-value 43 | ``` 44 | 45 | ... and import it into your code. 46 | 47 | ```javascript 48 | import { AnimatedValue } from 'animated-value'; 49 | 50 | const animatedOpacity = new AnimatedValue(/*...*/); 51 | // ... 52 | ``` 53 | 54 | If you're not to keen on reading gobs of documentation, feel free to skip down to the **Examples** section below. 55 | 56 | ### `AnimatedValue` 57 | 58 | The `AnimatedValue` class represents a value (usually a CSS or text number value) that you can define an animation for. An animation is defined by three things: 59 | 60 | - `start`: The initial value of the `AnimatedValue` 61 | - `end`: The final value of the `AnimatedValue` 62 | - `ease`: An easing function that maps `[0, 1)` to itself, mapping time to progress. 63 | 64 | The **start** and **end** values are numbers. For example, if we want to reveal a component by increasing the opacity from 0 to 1, we would have `start: 0` and `end: 1`. We can also update the start and end positions of existing animated values by setting the `.start` and `.end` properties of the animated value instance. Note that if an animation is in progress, this will cause the animation to "jump" to the updated value in the next frame, unless we first pause the animation, update, and resume from the new position. 65 | 66 | The **ease** argument can be a function that maps time to progress through the animation (both on a 0 to 1 scale), like `t => Math.pow(t, 2)`, or an array of four numbers that define a cubic Bezier curve, like `[0, 1, 1, 0]`. 67 | 68 | An `AnimatedValue` object has a **value** at any given time, and you can ask for the value at the current time by calling `AnimatedValue#value()`. If the animation hasn't started yet, this will be initialized to the start value. You can manually set the current value of an animation using `AnimatedValue#set(value)`, which may be useful when controlling animated values in response to user input like dragging. 69 | 70 | When we render declaratively, we can use the `#value()` getter to get the value of an animated property at render time (see examples below for reference). 71 | 72 | ### Easing curves 73 | 74 | The library comes bundled with a short list of useful easing functions you can pass to the `AnimatedValue` constructor, under `AnimatedValue.CURVES`. This includes the full set of CSS default named easing curves (`CURVES.EASE_IN`, `CURVES.EASE_OUT`, `CURVES.EASE_IN_OUT`, `CURVES.LINEAR`, etc.) as well as a few extras: 75 | 76 | - `EXPO_IN`: a sharper ease-in, with a more dramatic acceleration and an elastic feel 77 | - `EXPO_OUT`: a sharper ease-out 78 | - `EXPO_IN_OUT`: a sharper ease-in-out 79 | - `EASE_IN_BACK`: an ease-in that retreats a bit before shooting to the end (reverse of `EASE_OUT_BACK`) 80 | - `EASE_OUT_BACK`: an ease-out that overshoots a bit before returning to the end 81 | 82 | You can check out the exact definitions of all the pre-defined easing curves in `src/index.js`, defined using cubic Bezier coefficients. 83 | 84 | ### Controlling animations 85 | 86 | The power in `animated-value` comes in more than being able to define animations -- CSS can do that just fine. With `AnimatedValue` objects, we can finely control when animations start, stop, pause, and get reset, and we can group animations into larger groups of animated properties to control them together, as a single unit of animation. 87 | 88 | Let's create an animated value for opacity, for a fade-in effect: 89 | 90 | ```javascript 91 | const animatedOpacity = new AnimatedValue({ 92 | start: 0, 93 | end: 1, 94 | }); 95 | 96 | animatedOpacity.value(); // 0, our start value, since we haven't started the animation yet 97 | ``` 98 | 99 | To play the animation, we call `play()` with a duration, in milliseconds. 100 | 101 | ```javascript 102 | animatedOpacity.play(2000); // play for two seconds 103 | 104 | // one second later... 105 | animatedOpacity.value(); 106 | // returns 1, since we're halfway through the (linear) animation from 0 to 1 107 | ``` 108 | 109 | But it's no good if the value is never rendered to the UI. In rendering, `animated-value` is framework-agnostic (as you can see in the examples down below). To render the animated property to the UI, we can simply slot the animated value's `value()` in our rendering code. Here's one way to do it. 110 | 111 | ```javascript 112 | class MyAnimatedComponent extends React.Component { 113 | 114 | constructor(props) { 115 | super(props); 116 | this.animatedOpacity = new AnimatedValue({ 117 | start: 0, 118 | end: 1, 119 | }); 120 | } 121 | 122 | // We'll call this method to start the animation 123 | startAnimation() { 124 | this.animatedOpacity.play(2000); 125 | } 126 | 127 | render() { 128 | return
132 |
; 133 | } 134 | 135 | } 136 | ``` 137 | 138 | This way, when the component is rendered, the opacity style will be the current value of the animated opacity property. 139 | 140 | To make this animation work, we also need to make sure we're re-rendering the component every frame. We can use `requestAnimationFrame` to achieve this, by forcing a re-render every frame using something like `Component.forceUpdate()`. But `animated-value` comes with a built-in way of invoking an update every frame while an animation is running. 141 | 142 | `AnimatedValue#play()` takes a second argument, which is a callback that'll be called every single frame until the animation is finished. We can call `play()` with the callback as an update to the local state, and React will re-render the component with the right opacity every frame. 143 | 144 | ```javascript 145 | class MyAnimatedComponent extends React.Component { 146 | 147 | constructor(props) { 148 | super(props); 149 | this.animatedOpacity = new AnimatedValue({ 150 | start: 0, 151 | end: 1, 152 | }); 153 | this.state = { 154 | opacity: this.animatedOpacity.value(), 155 | } 156 | } 157 | 158 | // We'll call this method to start the animation 159 | startAnimation() { 160 | this.animatedOpacity.play(2000, () => { 161 | // This will be called every frame, with a new opacity value 162 | this.setState({ 163 | opacity: this.animatedOpacity.value(), 164 | }); 165 | }); 166 | } 167 | 168 | render() { 169 | return
172 |
; 173 | } 174 | 175 | } 176 | ``` 177 | 178 | And we've animated our React component with `animated-value`! 179 | 180 | Of course, if this was all we could do, there wouldn't be use for such an elaborate solution. Since we have a handle on the `animatedOpacity` object, we can play, pause, and reset/re-play the animation at any time, in response to user events, timers, or anything else in your code. 181 | 182 | We can pause the animation anytime through the play with `pause()`, and reset the animation to its original state with `reset()`. Both of these can be called at any time during or before/after animation plays. 183 | 184 | ```javascript 185 | animatedOpacity.play(2000); 186 | animatedOpacity.pause(); // pause the animation where it is 187 | animatedOpacity.resume(); // resume the animation from where it was left off 188 | 189 | animatedOpacity.play(2000); 190 | animatedOpacity.reset(); // stop the animation immediately where it is, and reset to the start 191 | 192 | animatedOpacity.play(2000); 193 | // calling play again in the middle of an animation will be ignored. 194 | animatedOpacity.play(2000); // ignored 195 | // to re-start an animation, call `reset()`, then `play()` again. 196 | ``` 197 | 198 | ### Chaining animations with the Promise API 199 | 200 | `AnimatedValue#play()` returns a promise that resolves as soon as the animation is either complete, or reset. 201 | 202 | If the animation runs to its full completion (even after pauses and resumes), the returned promise will resolve to `true`. If the animation is reset at some point and fails to run to completion, it'll resolve to `false`. The promise returned from `play()` never rejects. 203 | 204 | If you can `play()` multiple times in a row without resetting in between, you'll receive the same promise multiple times. 205 | 206 | This allows us to chain animations and other actions together. 207 | 208 | ```javascript 209 | const animatedOpacity = new AnimatedOpacity({ 210 | start: 0, 211 | end: 1, 212 | ease: AnimatedValue.CURVES.EASE_OUT, 213 | }); 214 | 215 | animatedOpacity.play(2000).then((finished) => { 216 | if (finished) { 217 | console.log('Animation ran successfully to the end'); 218 | } else { 219 | console.log('Animation was reset in the middle!'); 220 | } 221 | }); 222 | ``` 223 | 224 | ### Composite animations 225 | 226 | Frequently, we have to animate multiple properties concurrently. If we're revealing a dialog box, for example, we may want to animate the opacity, vertical position, and scale of the box all together, in the same duration. 227 | 228 | Rather than calling `play()` on each animated value, we can compose these related animations together into a _Composite_ animated value. We can treat composite values exactly the same as normal, single animated values, with one difference -- we can't call `value()` to ask for the current value of a composite animated property, since it doesn't make sense for a set of properties to have a single value. 229 | 230 | For example, let's create a composite animated value that combines travel in the x- and y-directions, so it looks like the animated component is "swinging" on its way to the diagonal end. 231 | 232 | We can create a composite animated value by passing individual animated values into `AnimatedValue.compose()`: 233 | 234 | ```javascript 235 | const animatedX = new AnimatedValue({ 236 | start: 0, 237 | end: 100, // 100px end 238 | ease: AnimatedValue.CURVES.EASE_OUT, 239 | }); 240 | const animatedY = new AnimatedValue({ 241 | start: 0, 242 | end: 100, // 100px end 243 | ease: AnimatedValue.CURVES.EASE_IN, 244 | }); 245 | const animatedSwing = AnimatedValue.compose(animatedX, animatedY); 246 | 247 | // play both animation values together, for 2000ms 248 | animatedSwing.play(2000); 249 | ``` 250 | 251 | Composite values like this have the same play/pause/resume/reset API as singular `AnimatedValue`s. In fact, the animations API are polymorphic under the hood -- you can pass composite animated values as sub-animations to bigger composite animated values! 252 | 253 | ## Spring physics and `AnimatedValue.Kinetic` 254 | 255 | Going beyond simple easing-based animations, one way to add more liveliness and delight into UI animation is to use spring physics-based animation. This means that, rather than depending on Bezier curves for defining the progression of a value over time, we'll treat the value as if it had inertia and were pulled to new values by a string. 256 | 257 | You can check out [this excellent talk at WWDC 2017, titled "Designing Fluid Interfaces,"](https://developer.apple.com/videos/play/wwdc2018/803/) for an overview of physics-based animation in UI. 258 | 259 | `animated-value` provides a second kind of animation primitive, the **kinetic animated value**, from which we can build fluid, spring physics-based animations. Like `AnimatedValue`s, kinetic values can be applied to any numerical value and animated over time. We can create new kinetic values like this 260 | 261 | ```javascript 262 | const springPosition = new AnimatedValue.Kinetic({ 263 | start: 0, 264 | stiffness: 5, 265 | damping: .4, 266 | }); 267 | ... 268 | springPosition.playTo(250, () => render()); 269 | ``` 270 | 271 | There are two critical differences to remember when we're using kinetic values to build animated components. 272 | 273 | First, **kinetic values do not take end values**. Instead, we define a current value and animate the value _to_ a new destination value using `.playTo(newValue)`. 274 | 275 | Second, **kinetic values are parameterized by stiffness and damping, not an easing curve**. The stiffness and damping constants determine the behavior of the "spring" powering the animation. 276 | 277 | - The **damping** determines how "bouncy" the spring is and covers the range [0, 1). The higher the damping, the less bouncy the spring. 278 | - The **stiffness** determines how strong the spring's recoil force is. Feel free to experiment with these values to find a value that feels right! `animated-value` comes with sane defaults that should feel natural in most UIs. 279 | 280 | Like normal `AnimatedValue`s, `AnimatedValue.Kinetic` are also perfectly *reentrant, interruptible, smooth, and redirectable*. You can call `.playTo(endValue)` with new values repeatedly, even in the middle of other animations, and the value will transition smoothly and realistically to new values. 281 | 282 | Kinetic values by nature cannot be reset nor played without destination values, but they can still be paused and resumed at any time. Because of some of these API differences, and because it would simply be jarring in UI, kinetic values cannot be composed with `AnimatedValue.compose` -- doing so will result in console warnings and unsupported behaviors. 283 | 284 | ### Summary 285 | 286 | Using singular `AnimatedValue`s, we can define individual properties and how we want them to behave when we control our animations. And with composite animated values, we can define higher-level animations that correspond to a concept, like "reveal" or "bounce out". In either case, `animated-value` is great for animations that we want to be able to imperatively control tightly inside a component. 287 | 288 | ## Examples 289 | 290 | `animated-value` was made to fit right into declarative component frameworks on the web, so the best way to illustrate its API might be to show some use cases. Here, I've written one way to use the library for **React** and **[Torus](https://github.com/thesephist/torus)**. 291 | 292 | ### React 293 | 294 | Here's an example of `AnimatedValue` used in a React component to animate a reveal-in motion. 295 | 296 | In just a few extra lines, we've defined fully controllable, 60fps-animated properties on our component that fits right into React's declarative rendering style while being fully controllable from our application logic. 297 | 298 | ```javascript 299 | class AnimatedSquare extends Component { 300 | 301 | constructor(props) { 302 | super(props); 303 | this.state = { 304 | opacity: 0, 305 | xOffset: 0, 306 | } 307 | 308 | this.animatedOpacity = new AnimatedValue({ 309 | start: 0, 310 | end: 1, 311 | // linear easing by default 312 | }); 313 | this.animatedXOffset = new AnimatedValue({ 314 | start: 0, 315 | end: 200, 316 | ease: AnimatedValue.CURVES.EASE_OUT_BACK, 317 | }); 318 | // we want to run both concurrently, as a "reveal" animation 319 | this.animatedReveal = AnimatedValue.compose( 320 | this.animatedOpacity, 321 | this.animatedXOffset, 322 | ); 323 | } 324 | 325 | reveal() { 326 | // Play the animation for 800ms 327 | this.animatedReveal.play(800, () => { 328 | this.setState({ 329 | opacity: this.animatedOpacity.value(), 330 | xOffset: this.animatedXOffset.value(), 331 | }); 332 | }); 333 | } 334 | 335 | componentDidMount() { 336 | // start the animation on mount 337 | this.reveal(); 338 | } 339 | 340 | render() { 341 | const animatedProperties = { 342 | opacity: this.state.opacity, 343 | transform: `translateX(${this.state.xOffset})`, 344 | } 345 | return ( 346 |
347 |
348 | ); 349 | } 350 | 351 | } 352 | ``` 353 | 354 | ### Torus 355 | 356 | [Torus](https://github.com/thesephist/torus) is a lightweight UI framework I wrote with a declarative UI rendering API, and it goes well with `animated-value`. Here's what the equivalent component and animation would look like in Torus. 357 | 358 | ```javascript 359 | class AnimatedSquare extends Component { 360 | 361 | init() { 362 | this.animatedOpacity = new AnimatedValue({ 363 | start: 0, 364 | end: 1, 365 | // linear easing by default 366 | }); 367 | this.animatedXOffset = new AnimatedValue({ 368 | start: 0, 369 | end: 200, 370 | ease: AnimatedValue.CURVES.EASE_OUT_BACK, 371 | }); 372 | // we want to run both concurrently, as a "reveal" animation 373 | this.animatedReveal = AnimatedValue.compose( 374 | this.animatedOpacity, 375 | this.animatedXOffset 376 | ); 377 | 378 | // start the animation 379 | this.reveal(); 380 | } 381 | 382 | reveal() { 383 | // Run the reveal animation for 800ms, and re-render each frame 384 | // (that's what the call to `this.render()` does). 385 | this.animatedReveal.play(800, () => this.render()); 386 | } 387 | 388 | compose() { 389 | return jdom`
`; 393 | } 394 | 395 | } 396 | ``` 397 | -------------------------------------------------------------------------------- /docs/src/spring.js.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ./src/spring.js annotated source 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |

./src/spring.js annotated source

16 | Back to index 17 |
18 |

 19 |         
20 |
1/* springFactory
21 |
2 * modified from https://hackernoon.com/the-spring-factory-4c3d988e7129
22 |
3 *
23 |
4 * Generate a physically realistic easing curve for a damped mass-spring system.
24 |
5 *
25 |
6 * Required:
26 |
7 *   damping (zeta): [0, 1)
27 |
8 *   stiffness: 0...inf
28 |
9 *
29 |
10 * Optional:
30 |
11 *   initial_position: -1..1, default 1
31 |
12 *   initial_velocity: -inf..+inf, default 0
32 |
13 *
33 |
14 * Return: f(t), t in 0..1
34 |
15 */
35 |
16function springFactory({
36 |
17    damping = .8,
37 |
18    stiffness = 3,
38 |
19    initial_position = 1,
39 |
20    initial_velocity = 0,
40 |
21} = {}) {
41 |
22    const zeta = damping;
42 |
23    const k = stiffness;
43 |
24    const y0 = initial_position;
44 |
25    const v0 = initial_velocity;
45 |
26
46 |
27    const A = y0;
47 |
28    let B;
48 |
29    let omega;
49 |
30
50 |

If v0 is 0, an analytical solution exists, otherwise, we need to numerically solve it.

51 |
33    if (Math.abs(v0) < 1e-6) {
52 |
34        B = zeta * y0 / Math.sqrt(1 - (zeta * zeta));
53 |
35        omega = computeOmega(A, B, k, zeta);
54 |
36    } else {
55 |
37        const result = numericallySolveOmegaAndB({
56 |
38            zeta: zeta,
57 |
39            k: k,
58 |
40            y0: y0,
59 |
41            // Modified from original to add factor PI/2 to keep velocity
60 |
42            //  self-consistent.
61 |
43            v0: v0 / Math.PI / 2,
62 |
44        });
63 |
45
64 |
46        B = result.B;
65 |
47        omega = result.omega;
66 |
48    }
67 |
49
68 |
50    omega *= 2 * Math.PI;
69 |
51    const omega_d = omega * Math.sqrt(1 - (zeta * zeta));
70 |
52
71 |
53    return t => {
72 |
54        const sinusoid = (A * Math.cos(omega_d * t)) + (B * Math.sin(omega_d * t));
73 |
55        return Math.exp(-t * zeta * omega) * sinusoid;
74 |
56    };
75 |
57}
76 |
58
77 |
59function computeOmega(A, B, k, zeta) {
78 |
60
79 |

Haven't quite figured out why yet, but to ensure same behavior of k when argument of arctangent is negative, need to subtract pi otherwise an extra halfcycle occurs.

80 |
64
81 |
82 |

It has something to do with -atan(-x) = atan(x), the range of atan being (-pi/2, pi/2) which is a difference of pi.

83 |
67
84 |
85 |

The other way to look at it is that for every integer k there is a solution and the 0 point for k is arbitrary, we just want it to be equal to the thing that gives us the same number of halfcycles as k.

86 |
71    if (A * B < 0 && k >= 1) {
87 |
72        k --;
88 |
73    }
89 |
74
90 |
75    return (-Math.atan(A / B) + (Math.PI * k)) / (2 * Math.PI * Math.sqrt(1 - (zeta * zeta)));
91 |
76}
92 |
77
93 |
78
94 |

Resolve recursive definition of omega an B using bisection method

95 |
80function numericallySolveOmegaAndB({
96 |
81    zeta,
97 |
82    k,
98 |
83    y0 = 1,
99 |
84    v0 = 0,
100 |
85} = {}) {
101 |

See Underdamping on Wikipedia. B and omega are recursively defined in solution. Know omega in terms of B, will numerically solve for B.

102 |
89    function errorfn(B, omega) {
103 |
90        const omega_d = omega * Math.sqrt(1 - (zeta * zeta));
104 |
91        return B - (((zeta * omega * y0) + v0) / omega_d);
105 |
92    }
106 |
93
107 |

Initial guess that's pretty close

108 |
95    const A = y0;
109 |
96    let B = zeta;
110 |
97
111 |
98    let omega;
112 |
99    let error;
113 |
100    let direction;
114 |
101
115 |
102    function step() {
116 |
103        omega = computeOmega(A, B, k, zeta);
117 |
104        error = errorfn(B, omega);
118 |
105        direction = -Math.sign(error);
119 |
106    }
120 |
107
121 |
108    step();
122 |
109
123 |
110    const tolerence = 1e-6;
124 |
111    let lower;
125 |
112    let upper;
126 |
113
127 |
114    let ct = 0;
128 |
115    const maxct = 1e3;
129 |
116
130 |
117    if (direction > 0) {
131 |
118        while (direction > 0) {
132 |
119            ct ++;
133 |
120
134 |
121            if (ct > maxct) {
135 |
122                break;
136 |
123            }
137 |
124
138 |
125            lower = B;
139 |
126
140 |
127            B *= 2;
141 |
128            step();
142 |
129        }
143 |
130
144 |
131        upper = B;
145 |
132    } else {
146 |
133        upper = B;
147 |
134
148 |
135        B *= -1;
149 |
136
150 |
137        while (direction < 0) {
151 |
138            ct ++;
152 |
139
153 |
140            if (ct > maxct) {
154 |
141                break;
155 |
142            }
156 |
143
157 |
144            lower = B;
158 |
145
159 |
146            B *= 2;
160 |
147            step();
161 |
148        }
162 |
149
163 |
150        lower = B;
164 |
151    }
165 |
152
166 |
153    while (Math.abs(error) > tolerence) {
167 |
154        ct ++;
168 |
155
169 |
156        if (ct > maxct) {
170 |
157            break;
171 |
158        }
172 |
159
173 |
160        B = (upper + lower) / 2;
174 |
161        step();
175 |
162
176 |
163        if (direction > 0) {
177 |
164            lower = B;
178 |
165        } else {
179 |
166            upper = B;
180 |
167        }
181 |
168    }
182 |
169
183 |
170    return {
184 |
171        omega,
185 |
172        B,
186 |
173    };
187 |
174}
188 |
175
189 |

Export a namespaced version of the spring curve solver.

190 |
177export {
191 |
178    springFactory,
192 |
179}
193 |
180
194 |
195 | 196 | 197 | 202 | 203 | 204 | -------------------------------------------------------------------------------- /docs/src/index.js.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ./src/index.js annotated source 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |

./src/index.js annotated source

16 | Back to index 17 |
18 |

 19 |         
20 |

Animation curves are usually defined as cubic Bezier curves, but it turns out computing these functions on the fly from the time domain is pretty tricky. We depend on this ~500B library to resolve Bezier curves for us.

21 |
5import * as Bezier from 'bezier-easing';
22 |
6
23 |

Spring physics-based easing functions comes from this external dependency that resolves spring physics curves into functions.

24 |
9import {springFactory} from './spring.js';
25 |
10
26 |

Linear easing curve

27 |
12const identity = t => t;
28 |
13
29 |

Animation involves lots of measuring time. This is a shortcut to get the current unix epoch time in milliseconds.

30 |
17const now = () => Date.now();
31 |
18
32 |

AnimatedValue's animation objects are state machines, with three states. These three states are represented as these constants.

33 |
22const STATE_UNSTARTED = 0;
34 |
23const STATE_PLAYING = 1;
35 |
24const STATE_PAUSED = 2;
36 |
25
37 |

By default, AnimatedValue.CURVES provides a useful set of easing curves we can use out of the box.

38 |
28const CURVES = {
39 |
29    LINEAR: identity,
40 |
30    EASE: Bezier(0.25, 0.1, 0.25, 1),
41 |
31    EASE_IN: Bezier(0.42, 0, 1, 1),
42 |
32    EASE_OUT: Bezier(0, 0, 0.58, 1),
43 |
33    EASE_IN_OUT: Bezier(0.42, 0, 0.58, 1),
44 |
34    EASE_IN_BACK: Bezier(0.6, -0.28, 0.735, 0.045),
45 |
35    EASE_OUT_BACK: Bezier(0.175, 0.885, 0.32, 1.275),
46 |
36    EXPO_IN: Bezier(0.95, 0.05, 0.795, 0.035),
47 |
37    EXPO_OUT: Bezier(0.19, 1, 0.22, 1),
48 |
38    EXPO_IN_OUT: Bezier(1, 0, 0, 1),
49 |
39}
50 |
40
51 |

Unified frame loop

52 |
42
53 |
54 |

This section implements a unified frame loop for all animations performed by animated-value. Rather than each animated value orchestrating its own animation frame loop, if we implement one frame loop for the whole library and hook into it from each animated value, we can save many unnecessary calls to and from requestAnimationFrame during animation and keep code efficient.

55 |
48
56 |
57 |

Is the unified frame loop (UFL) currently running?

58 |
50let rafRunning = false;
59 |
60 |

Queue of callbacks to be run on the next animation frame

61 |
52let rafQueue = [];
62 |
63 |

The function that runs at most once every animation frame. This calls all queued callbacks once, and if necessary, enqueues a recursive call in the next frame.

64 |
56const runAnimationFrame = () => {
65 |
57    requestAnimationFrame(() => {
66 |
58        const q = rafQueue;
67 |
59        rafQueue = [];
68 |
60        for (const cb of q) {
69 |
61            cb();
70 |
62        }
71 |
63        if (rafQueue.length > 0) {
72 |
64            runAnimationFrame();
73 |
65        } else {
74 |
66            rafRunning = false;
75 |
67        }
76 |
68    });
77 |
69}
78 |

The animation frame callback that enqueues new callbacks into the next frame callback and optionally starts the frame loop if one is not running.

79 |
72const raf = callback => {
80 |
73    rafQueue.push(callback);
81 |
74
82 |
75    if (!rafRunning) {
83 |
76        rafRunning = true;
84 |
77        runAnimationFrame();
85 |
78    }
86 |
79}
87 |
80
88 |

Playable interface

89 |
82
90 |
91 |

The Playable class represents something whose timeline can be played, paused, and reset. Both a single AnimatedValue, as well as CompositeAnimatedValue (a combination of more than one AV) inherit from Playable. This class enables us to have polymorphic, imperative animation control APIs.

92 |
88class Playable {
93 |
89
94 |
90    constructor() {
95 |

A Playable is a state machine.

96 |
92        this.state = STATE_UNSTARTED;
97 |
98 |

The duration requested for an animation play-through

99 |
94        this._duration = null;
100 |
101 |

When did our current animation run start? null if the animation is not currently playing.

102 |
97        this._startTime = null;
103 |
104 |

If we are paused, we keep track of how far through the animation we were here.

105 |
100        this._pausedTime = null;
106 |
107 |

Callback after each frame is rendered during play

108 |
102        this._callback = null;
109 |
110 |

A promise that resolves when the currently playing animation either finishes (resolves to true) or is reset (resolves to false).

111 |
105        this._promise = Promise.resolve();
112 |
113 |

Temporary variable we use as a pointer to the resolver of this._promise, so we can resolve the promise outside of the promise body.

114 |
108        this._promiseResolver = null;
115 |
109    }
116 |
110
117 |
111    play(duration, callback) {
118 |
112        if (this.state === STATE_PLAYING) {
119 |
113            return this._promise;
120 |
114        }
121 |
115
122 |
116        this.state = STATE_PLAYING;
123 |
117        this._duration = duration;
124 |
118        this._startTime = now();
125 |
119
126 |
120        this._promise = new Promise((res, _rej) => {
127 |
121            this._promiseResolver = res;
128 |
122            this._callback = () => {
129 |
123                if (callback !== undefined) {
130 |
124                    callback();
131 |
125                }
132 |

Sometimes, in between frames, the user will pause the animation but we assume it isn't paused so we keep playing. This checks if we're supposed to still be playing the next frame.

133 |
130                if (this.state === STATE_PLAYING) {
134 |
131                    if (now() - this._startTime > this._duration) {
135 |
132                        this.pause();
136 |
133                        if (this._promiseResolver !== null) {
137 |
134                            this._promiseResolver(true);
138 |
135                            this._promiseResolver = null;
139 |
136                        }
140 |
137                    } else {
141 |
138                        raf(this._callback);
142 |
139                    }
143 |
140                }
144 |
141            }
145 |
142            this._callback();
146 |
143        });
147 |
144        return this._promise;
148 |
145    }
149 |
146
150 |
147    pause() {
151 |
148        if (this.state === STATE_PLAYING) {
152 |
149            this.state = STATE_PAUSED;
153 |
150            this._pausedTime = now() - this._startTime;
154 |
151            this._startTime = null;
155 |
152        }
156 |
153    }
157 |
154
158 |
155    resume() {
159 |
156        if (this.state === STATE_PAUSED) {
160 |
157            this.state = STATE_PLAYING;
161 |
158            this._startTime = now() - this._pausedTime;
162 |
159            this._pausedTime = null;
163 |
160            this._callback();
164 |
161        }
165 |
162    }
166 |
163
167 |
164    reset() {
168 |
165        if (this._promiseResolver !== null) {
169 |
166            this._promiseResolver(false);
170 |
167            this._promiseResolver = null;
171 |
168        }
172 |
169        this.state = STATE_UNSTARTED;
173 |
170        this._duration = null;
174 |
171        this._startTime = null;
175 |
172        this._pausedTime = null;
176 |
173        this._callback = null;
177 |
174    }
178 |
175
179 |
176}
180 |
177
181 |

AnimatedValue represents a single value (number) that can be animated with a duration and an easing curve. Most often, an AnimatedValue corresponds to a single CSS property that we're animating on a component, like translate / scale / opacity.

182 |
182class AnimatedValue extends Playable {
183 |
183
184 |
184    constructor({
185 |
185        start = 0,
186 |
186        end = 1,
187 |
187        ease = identity,
188 |
188    } = {}) {
189 |
189        super();
190 |
190        this.start = start;
191 |
191        this.end = end;
192 |

We accept an array of cubic Bezier points as an easing function, in which case we create a Bezier curve out of them.

193 |
194        this.ease = Array.isArray(ease) ? Bezier(...ease) : ease;
194 |
195 |

The fill state represents the value of the animated value whenever the animation is not running. The value is initialized to the start position.

196 |
197        this._fillState = start;
197 |
198    }
198 |
199
199 |

Statically defined so consumers of the API can define easing curves as AnimatedValue.CURVES.[CURVE_NAME].

200 |
202    static get CURVES() {
201 |
203        return CURVES;
202 |
204    }
203 |
205
204 |

Statically defined constructor for a composite animation, which takes multiple Playables (either single or composite animated values) and plays all of them concurrently.

205 |
209    static compose(...playables) {
206 |
210        return new CompositeAnimatedValue(playables);
207 |
211    }
208 |
212
209 |
213    static get Kinetic() {
210 |
214        return KineticValue;
211 |
215    }
212 |
216
213 |
217    set(value) {
214 |
218        this._fillState = value;
215 |
219    }
216 |
220
217 |

What's the current numerical value of this animated value? This API is intentionally not implemented as a getter, to communicate to the API consumer that value computation has a nonzero cost with each access.

218 |
224    value() {
219 |
225        if (this.state !== STATE_PLAYING) {
220 |

If the animation is not playing, just return the last fill value

221 |
227            return this._fillState;
222 |
228        } else {
223 |
229            const elapsedTime = now() - this._startTime;
224 |
230            const elapsedDuration = elapsedTime > this._duration ? 1 : elapsedTime / this._duration;
225 |
231            return ((this.end - this.start) * this.ease(elapsedDuration)) + this.start;
226 |
232        }
227 |
233    }
228 |
234
229 |
235    pause() {
230 |
236        if (this.state === STATE_PLAYING) {
231 |

The order of super call matters here, because we can't get the value if we aren't playing

232 |
238            this._fillState = this.value();
233 |
239        }
234 |
240        super.pause();
235 |
241    }
236 |
242
237 |
243    reset() {
238 |
244        super.reset();
239 |
245        this._fillState = this.start;
240 |
246    }
241 |
247
242 |
248}
243 |
249
244 |

A CompositeAnimatedValue is a Playable wrapper around many (single, composite) animated values, that can run all of the animations in the same duration, concurrently. CompositeAnimatedValue is polymorphic, and can take any Playable as a sub-animation.

245 |
253class CompositeAnimatedValue extends Playable {
246 |
254
247 |
255    constructor(playables) {
248 |
256        super();
249 |
257        this._playables = playables;
250 |
258        for (const p of this._playables) {
251 |
259            if (p instanceof KineticValue) {
252 |
260                console.warn('AnimatedValue.Kinetic cannot be composed into composite animated values. Doing so may result in buggy and undefined behavior.');
253 |
261            }
254 |
262        }
255 |
263    }
256 |
264
257 |
265    play(duration, callback) {
258 |

CompositeAnimatedValue#play() swallows the callback here and calls it once for the entire composition, efficiently, so the callback isn't called N times for N animated values below this composite animation.

259 |
269        const ret = super.play(duration, callback);
260 |
270        for (const av of this._playables) {
261 |
271            av.play(duration);
262 |
272        }
263 |
273        return ret;
264 |
274    }
265 |
275
266 |
276    pause() {
267 |
277        super.pause();
268 |
278        for (const av of this._playables) {
269 |
279            av.pause();
270 |
280        }
271 |
281    }
272 |
282
273 |
283    resume() {
274 |
284        if (this.state === STATE_PAUSED) {
275 |
285            for (const av of this._playables) {
276 |
286                av.resume();
277 |
287            }
278 |
288        }
279 |

Order of super.resume() call is important -- if we resume first, the checks above don't work

280 |
290        super.resume();
281 |
291    }
282 |
292
283 |
293    reset() {
284 |
294        super.reset();
285 |
295        for (const av of this._playables) {
286 |
296            av.reset();
287 |
297        }
288 |
298    }
289 |
299
290 |
300}
291 |
301
292 |

A KineticValue or AnimatedValue.Kinetic is an animated value whose animations are defined by spring physics. As such, it takes only a starting position and some phsyics constants, and are aniamted to destination coordinates. Kinetic values also cannot be reset.

293 |
305class KineticValue extends AnimatedValue {
294 |
306
295 |
307    constructor({
296 |
308        start = 0,
297 |
309        end = null,
298 |
310        stiffness = 3,
299 |
311        damping = .8,
300 |
312        duration = 1000,
301 |
313    } = {}) {
302 |
314        if (end === null) {
303 |
315            end = start;
304 |
316        }
305 |
317        stiffness = ~~stiffness;
306 |
318
307 |
319        const ease = springFactory({
308 |
320            damping,
309 |
321            stiffness,
310 |
322            initial_velocity: 0,
311 |
323        });
312 |
324        super({
313 |
325            start,
314 |
326            end,
315 |

Functions from springFactory start at 1 and go to 0, so we need to invert it for our use case.

316 |
329            ease: t => 1 - ease(t),
317 |
330        });
318 |
331        this.damping = damping;
319 |
332        this.stiffness = stiffness;
320 |

In kinetic physics-based animations, the animation duration is a parameter over the whole spring, not a single animation. So we set it for the value itself and store it here to use it in every animation instance.

321 |
336        this._dynDuration = duration;
322 |
337    }
323 |
338
324 |

playTo() substitutes play() for kinetic values, and is the way to animate the spring animated value to a new value.

325 |
341    playTo(end, callback) {
326 |
342        const n = now();
327 |

Get elapsed time scaled to the range [0, 1]

328 |
344        const elapsed = (n - this._startTime) / this._duration;
329 |
345
330 |

Determine instantaneous velocity

331 |
347        const DIFF = 0.0001;
332 |
348        const velDiff = (this.ease(elapsed) - this.ease(elapsed - DIFF)) / DIFF;
333 |
349
334 |

Scale the velocity to the new start and end coordinates, since the distance covered will modify how the [0, 1] range scales out to real values.

335 |
352        const scaledVel = velDiff * (this.end - this.start) / (end - this.value());
336 |
353
337 |

Create a new easing curve based on the new velocity

338 |
355        const ease = springFactory({
339 |
356            damping: this.damping,
340 |
357            stiffness: this.stiffness,
341 |
358            initial_velocity: -scaledVel,
342 |
359        });
343 |

Reset animation values so the next frame will render using the new animation parameters

344 |
361        this.start = this.value();
345 |
362        this.end = end;
346 |
363        this.ease = t => 1 - ease(t);
347 |
364        this._startTime = n;
348 |
365
349 |

If there is not already an animation running, start it.

350 |
367        if (this.state !== STATE_PLAYING) {
351 |
368            super.play(this._dynDuration, callback);
352 |
369        }
353 |

Return promise for chaining calls.

354 |
371        return this._promise;
355 |
372    }
356 |
373
357 |

Warnings for APIs that do not apply to kinetic values

358 |
375    play() {
359 |
376        console.warn('Kinetic Animated Values should be played with playTo()');
360 |
377    }
361 |
378
362 |
379    reset() {
363 |
380        console.warn('Kinetic Animated Values cannot be reset');
364 |
381    }
365 |
382
366 |
383}
367 |
384
368 |
385if (typeof window === 'object') {
369 |
386    window.AnimatedValue = AnimatedValue;
370 |
387} else if (module && module.exports) {
371 |
388    module.exports = {AnimatedValue};
372 |
389}
373 |
390
374 |
375 | 376 | 377 | 382 | 383 | 384 | --------------------------------------------------------------------------------