13 |
20 |
21 | 2 * modified from https://hackernoon.com/the-spring-factory-4c3d988e7129
22 |
23 | 4 * Generate a physically realistic easing curve for a damped mass-spring system.
24 |
25 |
26 | 7 * damping (zeta): [0, 1)
27 |
28 |
29 |
30 | 11 * initial_position: -1..1, default 1
31 | 12 * initial_velocity: -inf..+inf, default 0
32 |
33 | 14 * Return: f(t), t in 0..1
34 |
35 | 16function springFactory({
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | 24 const y0 = initial_position;
44 | 25 const v0 = initial_velocity;
45 |
46 |
47 |
48 |
49 |
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 |
55 | 37 const result = numericallySolveOmegaAndB({
56 |
57 |
58 |
59 | 41 // Modified from original to add factor PI/2 to keep velocity
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | 51 const omega_d = omega * Math.sqrt(1 - (zeta * zeta));
70 |
71 |
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 |
75 |
76 |
77 | 59function computeOmega(A, B, k, zeta) {
78 |
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 |
88 |
89 |
90 | 75 return (-Math.atan(A / B) + (Math.PI * k)) / (2 * Math.PI * Math.sqrt(1 - (zeta * zeta)));
91 |
92 |
93 |
94 | Resolve recursive definition of omega an B using bisection method
95 |
80function numericallySolveOmegaAndB({
96 |
97 |
98 |
99 |
100 |
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 |
106 |
107 | Initial guess that's pretty close
108 |
95 const A = y0;
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 | 103 omega = computeOmega(A, B, k, zeta);
117 | 104 error = errorfn(B, omega);
118 | 105 direction = -Math.sign(error);
119 |
120 |
121 |
122 |
123 | 110 const tolerence = 1e-6;
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 | 118 while (direction > 0) {
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 | 137 while (direction < 0) {
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 | 153 while (Math.abs(error) > tolerence) {
167 |
168 |
169 |
170 |
171 |
172 |
173 | 160 B = (upper + lower) / 2;
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 | Export a namespaced version of the spring curve solver.
190 |
177export {
191 |
192 |
193 |
194 |
195 |
13 |
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 |
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 |
26 | 12const identity = t => t;
28 |
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 |
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 |
37 | By default, AnimatedValue.CURVES provides a useful set of easing curves we can use out of the box.
38 |
28const CURVES = {
39 |
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 |
50 |
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 |
67 |
68 |
69 |
70 |
71 | 63 if (rafQueue.length > 0) {
72 |
73 |
74 |
75 |
76 |
77 |
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 |
82 |
83 |
84 |
85 |
86 |
87 |
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 |
94 |
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 |
116 |
117 | 111 play(duration, callback) {
118 | 112 if (this.state === STATE_PLAYING) {
119 | 113 return this._promise;
120 |
121 |
122 | 116 this.state = STATE_PLAYING;
123 | 117 this._duration = duration;
124 | 118 this._startTime = now();
125 |
126 | 120 this._promise = new Promise((res, _rej) => {
127 | 121 this._promiseResolver = res;
128 | 122 this._callback = () => {
129 | 123 if (callback !== undefined) {
130 |
131 |
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 |
136 | 133 if (this._promiseResolver !== null) {
137 | 134 this._promiseResolver(true);
138 | 135 this._promiseResolver = null;
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 | 144 return this._promise;
148 |
149 |
150 |
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 |
156 |
157 |
158 |
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 |
164 |
165 |
166 |
167 |
168 | 165 if (this._promiseResolver !== null) {
169 | 166 this._promiseResolver(false);
170 | 167 this._promiseResolver = null;
171 |
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 |
178 |
179 |
180 |
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 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
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 |
199 | Statically defined so consumers of the API can define easing curves as AnimatedValue.CURVES.[CURVE_NAME].
200 |
202 static get CURVES() {
201 |
202 |
203 |
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 |
208 |
209 | 213 static get Kinetic() {
210 |
211 |
212 |
213 |
214 | 218 this._fillState = value;
215 |
216 |
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 |
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 |
227 |
228 |
229 |
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 |
234 |
235 |
236 |
237 |
238 |
239 | 245 this._fillState = this.start;
240 |
241 |
242 |
243 |
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 |
247 | 255 constructor(playables) {
248 |
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 |
254 |
255 |
256 |
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 |
262 |
263 |
264 |
265 |
266 |
267 |
268 | 278 for (const av of this._playables) {
269 |
270 |
271 |
272 |
273 |
274 | 284 if (this.state === STATE_PAUSED) {
275 | 285 for (const av of this._playables) {
276 |
277 |
278 |
279 | Order of super.resume() call is important -- if we resume first, the checks above don't work
280 |
290 super.resume();
281 |
282 |
283 |
284 |
285 | 295 for (const av of this._playables) {
286 |
287 |
288 |
289 |
290 |
291 |
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 |
295 |
296 |
297 |
298 |
299 |
300 |
301 |
302 |
303 |
304 |
305 | 317 stiffness = ~~stiffness;
306 |
307 | 319 const ease = springFactory({
308 |
309 |
310 |
311 |
312 |
313 |
314 |
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 |
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 |
323 |
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 |
327 | Get elapsed time scaled to the range [0, 1]
328 |
344 const elapsed = (n - this._startTime) / this._duration;
329 |
330 | Determine instantaneous velocity
331 |
347 const DIFF = 0.0001;
332 | 348 const velDiff = (this.ease(elapsed) - this.ease(elapsed - DIFF)) / DIFF;
333 |
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 |
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 |
343 | Reset animation values so the next frame will render using the new animation parameters
344 |
361 this.start = this.value();
345 |
346 | 363 this.ease = t => 1 - ease(t);
347 |
348 |
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 |
353 | Return promise for chaining calls.
354 |
371 return this._promise;
355 |
356 |
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 |
361 |
362 |
363 | 380 console.warn('Kinetic Animated Values cannot be reset');
364 |
365 |
366 |
367 |
368 | 385if (typeof window === 'object') {
369 | 386 window.AnimatedValue = AnimatedValue;
370 | 387} else if (module && module.exports) {
371 | 388 module.exports = {AnimatedValue};
372 |
373 |
374 |
375 |