From d2b202d30a3f793691ea4820eca8d71c8c63642a Mon Sep 17 00:00:00 2001 From: Loren Brichter Date: Tue, 30 Apr 2019 12:21:45 -0400 Subject: [PATCH] Subtle tweaks to spring animations Use verlet-style integration for spring animations. Rather than keeping track of "value" and velocity over time, keep track of value and previous-value, and derive velocity from the delta every tick. This has a few benefits, including greater stability (position and velocity can't drift) and simplifying signature of tick_spring (no need to pass velocity back up). Pulling "settled" flag out of the return signature as well means return value is just "next value", simplifying code mapping over objects and arrays, and eliminating duplicated code across get_initial_velocity, get_threshold and tick_spring. Refactored "threshold" calcs, extremely inexpensive to do inline in tick_spring rather than create a parallel structure. Also fixes a rare pathological case where springs will never settle (reading through the code, could happen if velocity was non-zero during a set() where target==current, threshold will be calculated to be zero and settled will always be set to false, leading to infinite animations). Functional changes: In my experience dealing with spring animations, there are a handful of edge-cases where it is nice to have library support. 99% of the time, the only times you'd want to fudge 'stiffness' and 'damping' is during a live interaction (e.g. dragging something around). By providing an idiomatic mechanism hopefully the code around dealing with that could be simpler. I propose an additional "options" parameter to 'set()' and 'update()'. If passed {hard:true} the set will be considered a "hard" set, where you want the value to be set to the target value immediately. This could be extremely useful when implementing dragging for instance. If passed {soft:true} or {soft:}, the set will be considered a "soft" set, where momentum will be preserved for some duration before settling. This could be useful when implementing "throwing", e.g. after a drag, on mouseup, 'soft set' to some position and the user's previous momentum will be honored before settling down. Technically momentum preservation happens to a degree now, but aggressive stiffness and/or damping values make it nearly unapparent. This handles the case where you may want more aggressive or heavily underdamped springs but without the apparent velocity discontinuity that happens on throw. (As a real example, in FaceTime, note behavior when tossing around the picture-in-picture, or the iPhone X gestural behavior when tossing apps back to the home screen). Internally this is implemented by temporarily setting mass to infinity and ramping back to normal over some duration. "Hard sets" are also special-cased to trigger a same-frame set and fulfilment, leading to more responsive dragging. Best case is a one frame improvement in drag latency (noticed in Safari). This also handles the "old way" method of munging 'stiffness' and 'damping' to 1, so the improvement applies to existing code. --- src/motion/spring.js | 182 ++++++++++++++++--------------------------- 1 file changed, 65 insertions(+), 117 deletions(-) diff --git a/src/motion/spring.js b/src/motion/spring.js index 2a5824f08895..1f23d103f6e1 100644 --- a/src/motion/spring.js +++ b/src/motion/spring.js @@ -2,144 +2,92 @@ import { writable } from 'svelte/store'; // eslint-disable-line import/no-unreso import { loop } from 'svelte/internal'; // eslint-disable-line import/no-unresolved import { is_date } from './utils.js'; -function get_initial_velocity(value) { - if (typeof value === 'number' || is_date(value)) return 0; - - if (Array.isArray(value)) return value.map(get_initial_velocity); - - if (value && typeof value === 'object') { - const velocities = {}; - for (const k in value) velocities[k] = get_initial_velocity(value[k]); - return velocities; - } - - throw new Error(`Cannot spring ${typeof value} values`); -} - -function get_threshold(value, target_value, precision) { - if (typeof value === 'number' || is_date(value)) return precision * Math.abs((target_value - value)); - - if (Array.isArray(value)) return value.map((v, i) => get_threshold(v, target_value[i], precision)); - - if (value && typeof value === 'object') { - const threshold = {}; - for (const k in value) threshold[k] = get_threshold(value[k], target_value[k], precision); - return threshold; - } - - throw new Error(`Cannot spring ${typeof value} values`); -} - -function tick_spring(velocity, current_value, target_value, stiffness, damping, multiplier, threshold) { - let settled = true; - let value; - +function tick_spring(ctx, last_value, current_value, target_value) { if (typeof current_value === 'number' || is_date(current_value)) { const delta = target_value - current_value; - const spring = stiffness * delta; - const damper = damping * velocity; - - const acceleration = spring - damper; - - velocity += acceleration; - const d = velocity * multiplier; - - if (is_date(current_value)) { - value = new Date(current_value.getTime() + d); + const velocity = (current_value - last_value) / (ctx.dt||1/60); // guard div by 0 + const spring = ctx.opts.stiffness * delta; + const damper = ctx.opts.damping * velocity; + const acceleration = (spring - damper) * ctx.inv_mass; + const d = (velocity + acceleration) * ctx.dt; + + if (Math.abs(d) < ctx.opts.precision && Math.abs(delta) < ctx.opts.precision) { + return target_value; // settled } else { - value = current_value + d; - } - - if (Math.abs(d) > threshold || Math.abs(delta) > threshold) settled = false; - } - - else if (Array.isArray(current_value)) { - value = current_value.map((v, i) => { - const result = tick_spring( - velocity[i], - v, - target_value[i], - stiffness, - damping, - multiplier, - threshold[i] - ); - - velocity[i] = result.velocity; - if (!result.settled) settled = false; - return result.value; - }); - } - - else if (typeof current_value === 'object') { - value = {}; - for (const k in current_value) { - const result = tick_spring( - velocity[k], - current_value[k], - target_value[k], - stiffness, - damping, - multiplier, - threshold[k] - ); - - velocity[k] = result.velocity; - if (!result.settled) settled = false; - value[k] = result.value; + ctx.settled = false; // signal loop to keep ticking + return is_date(current_value) ? + new Date(current_value.getTime() + d) : current_value + d; } - } - - else { + } else if (Array.isArray(current_value)) { + return current_value.map((_, i) => + tick_spring(ctx, last_value[i], current_value[i], target_value[i])); + } else if (typeof current_value === 'object') { + let next_value = {}; + for (const k in current_value) + next_value[k] = tick_spring(ctx, last_value[k], current_value[k], target_value[k]); + return next_value; + } else { throw new Error(`Cannot spring ${typeof value} values`); } - - return { velocity, value, settled }; } export function spring(value, opts = {}) { const store = writable(value); + const { stiffness = 0.15, damping = 0.8, precision = 0.01 } = opts; - const { stiffness = 0.15, damping = 0.8, precision = 0.001 } = opts; - const velocity = get_initial_velocity(value); - - let task; + let last_time, task, current_token; + let last_value = value; let target_value = value; - let last_time; - let settled; - let threshold; - let current_token; - function set(new_value) { - target_value = new_value; - threshold = get_threshold(value, target_value, spring.precision); + let inv_mass = 1; + let inv_mass_recovery_rate = 0; + let cancel_task = false; + function set(new_value, opts = {}) { + target_value = new_value; const token = current_token = {}; + + if (opts.hard || (spring.stiffness >= 1 && spring.damping >= 1)) { + cancel_task = true; // cancel any running animation + last_time = window.performance.now(); + last_value = value; + store.set(value = target_value); + return new Promise(f => f()); // fulfil immediately + } else if (opts.soft) { + let rate = opts.soft === true ? .5 : +opts.soft; + inv_mass_recovery_rate = 1 / (rate * 60); + inv_mass = 0; // infinite mass, unaffected by spring forces + } if (!task) { last_time = window.performance.now(); - settled = false; - + cancel_task = false; + task = loop(now => { - ({ value, settled } = tick_spring( - velocity, - value, - target_value, - spring.stiffness, - spring.damping, - (now - last_time) * 60 / 1000, - threshold - )); + + if (cancel_task) { + cancel_task = false; + task = null; + return false; + } + + inv_mass = Math.min(inv_mass + inv_mass_recovery_rate, 1); + + const ctx = { + inv_mass, + opts: spring, + settled: true, // tick_spring may signal false + dt: (now - last_time) * 60 / 1000 + }; + const next_value = tick_spring(ctx, last_value, value, target_value); last_time = now; + last_value = value; + store.set(value = next_value); - if (settled) { - value = target_value; + if (ctx.settled) task = null; - } - - store.set(value); - return !settled; + return !ctx.settled; }); } @@ -152,7 +100,7 @@ export function spring(value, opts = {}) { const spring = { set, - update: fn => set(fn(target_value, value)), + update: (fn, opts) => set(fn(target_value, value), opts), subscribe: store.subscribe, stiffness, damping, @@ -160,4 +108,4 @@ export function spring(value, opts = {}) { }; return spring; -} \ No newline at end of file +}