diff --git a/packages/core/src/Controller.ts b/packages/core/src/Controller.ts index 8e1c699c5..cb258bcba 100644 --- a/packages/core/src/Controller.ts +++ b/packages/core/src/Controller.ts @@ -1,10 +1,11 @@ -import { is, each, OneOrMore, toArray, UnknownProps } from 'shared' +import { is, each, OneOrMore, toArray, UnknownProps, noop } from 'shared' import * as G from 'shared/globals' import { Lookup, Falsy } from './types/common' -import { inferTo } from './helpers' +import { inferTo, flush, hasDefaultProp } from './helpers' import { FrameValue } from './FrameValue' import { SpringPhase, CREATED, ACTIVE, IDLE } from './SpringPhase' +import { SpringValue, createLoopUpdate, createUpdate } from './SpringValue' import { getCombinedResult, AnimationResult, @@ -58,7 +59,10 @@ export class Controller protected _active = new Set() /** State used by the `runAsync` function */ - protected _state: RunAsyncState = {} + protected _state: RunAsyncState = { + pauseQueue: new Set(), + resumeQueue: new Set(), + } /** The event queues that are flushed once per frame maximum */ protected _events = { @@ -88,7 +92,7 @@ export class Controller */ get idle() { return ( - !this._state.promise && + !this._state.asyncTo && Object.values(this.springs as Lookup).every( spring => spring.idle ) @@ -201,7 +205,7 @@ export class Controller // The "onRest" queue is only flushed when all springs are idle. if (!isActive) { this._phase = IDLE - flush(onRest, (result, onRest) => { + flush(onRest, ([onRest, result]) => { result.value = values onRest(result) }) @@ -217,19 +221,6 @@ export class Controller } } -/** Basic helper for clearing a queue after processing it */ -function flush( - queue: Map, - iterator: (value: T, key: P) => void -): void -function flush(queue: Set, iterator: (value: T) => void): void -function flush(queue: any, iterator: any) { - if (queue.size) { - each(queue, iterator) - queue.clear() - } -} - /** * Warning: Props might be mutated. */ @@ -308,24 +299,24 @@ export function flushUpdate( scheduleProps(++ctrl['_lastAsyncId'], { props, state, - action(props, resolve) { - props.onRest = onRest as any - resolve( - runAsync( - asyncTo, - props, - state, - ctrl.get.bind(ctrl), - () => false, // TODO: add pausing to Controller - ctrl.start.bind(ctrl) as any, - ctrl.stop.bind(ctrl) - ) - ) + actions: { + pause: noop, + start(props, resolve) { + props.onRest = onRest as any + if (!props.cancel) { + resolve(runAsync(asyncTo, props, state, ctrl)) + } + // Prevent `cancel: true` from ending the current `runAsync` call, + // except when the default `cancel` prop is being set. + else if (hasDefaultProp(props, 'cancel')) { + cancelAsync(state, props.callId) + } + }, }, }) ) } - // Cancel an active "asyncTo" if desired. + // Respect the `cancel` prop when no keys are affected. else if (!props.keys && props.cancel === true) { cancelAsync(state, ctrl['_lastAsyncId']) } diff --git a/packages/core/src/SpringValue.ts b/packages/core/src/SpringValue.ts index cde69af55..a275ec9eb 100644 --- a/packages/core/src/SpringValue.ts +++ b/packages/core/src/SpringValue.ts @@ -34,9 +34,11 @@ import { import { callProp, computeGoal, - DEFAULT_PROPS, matchProp, inferTo, + flush, + mergeDefaultProps, + getDefaultProp, } from './helpers' import { FrameValue, isFrameValue } from './FrameValue' import { @@ -84,7 +86,10 @@ export class SpringValue extends FrameValue { protected _phase: SpringPhase = CREATED /** The state for `runAsync` calls */ - protected _state: RunAsyncState = {} + protected _state: RunAsyncState = { + pauseQueue: new Set(), + resumeQueue: new Set(), + } /** Some props have customizable default values */ protected _defaultProps = {} as SpringDefaultProps @@ -107,7 +112,7 @@ export class SpringValue extends FrameValue { } get idle() { - return !this.is(ACTIVE) && !this._state.promise + return !this.is(ACTIVE) && !this._state.asyncTo } get goal() { @@ -306,7 +311,10 @@ export class SpringValue extends FrameValue { */ pause() { checkDisposed(this, 'pause') - this._phase = PAUSED + if (!this.is(PAUSED)) { + this._phase = PAUSED + flush(this._state.pauseQueue, onPause => onPause()) + } } /** Resume the animation if paused. */ @@ -314,10 +322,7 @@ export class SpringValue extends FrameValue { checkDisposed(this, 'resume') if (this.is(PAUSED)) { this._start() - - if (this._state.asyncTo) { - this._state.unpause!() - } + flush(this._state.resumeQueue, onResume => onResume()) } } @@ -434,7 +439,9 @@ export class SpringValue extends FrameValue { if (event.type == 'change') { if (!this.is(ACTIVE)) { this._reset() - this._start() + if (!this.is(PAUSED)) { + this._start() + } } } else if (event.type == 'priority') { this.priority = event.priority + 1 @@ -510,6 +517,24 @@ export class SpringValue extends FrameValue { /** Schedule an animation to run after an optional delay */ protected _update(props: SpringUpdate, isLoop?: boolean): AsyncResult { + type DefaultProps = typeof defaultProps + const defaultProps = this._defaultProps + const mergeDefaultProp = (key: keyof DefaultProps) => { + const value = getDefaultProp(props, key) + if (!is.und(value)) { + defaultProps[key] = value as any + } + // For `cancel` and `pause`, a truthy default always wins. + if (defaultProps[key]) { + props[key] = defaultProps[key] as any + } + } + + // These props are coerced into booleans by the `scheduleProps` function, + // so they need their default values processed before then. + mergeDefaultProp('cancel') + mergeDefaultProp('pause') + // Ensure the initial value can be accessed by animated components. const range = this._prepareNode(props) @@ -517,8 +542,9 @@ export class SpringValue extends FrameValue { key: this.key, props, state: this._state, - action: (props, resolve) => { - this._merge(range, props, resolve) + actions: { + pause: this.pause.bind(this), + start: this._merge.bind(this, range), }, }).then(result => { if (props.loop && result.finished && !(isLoop && result.noop)) { @@ -573,6 +599,8 @@ export class SpringValue extends FrameValue { onDelayEnd(props, this) } + mergeDefaultProps(defaultProps, props, ['pause', 'cancel']) + const { to: prevTo, from: prevFrom } = anim let { to = prevTo, from = prevFrom } = range @@ -608,8 +636,7 @@ export class SpringValue extends FrameValue { if (props.default) { each(DEFAULT_PROPS, prop => { - // Default props can only be null, an object, or a function. - if (/^(function|object)$/.test(typeof props[prop])) { + if (prop in props) { defaultProps[prop] = props[prop] as any } }) @@ -627,8 +654,10 @@ export class SpringValue extends FrameValue { mergeConfig( config, callProp(props.config, key!), - // Avoid calling the "config" prop twice when "default" is true. - props.default ? undefined : callProp(defaultProps.config, key!) + // Avoid calling the same "config" prop twice. + props.config !== defaultProps.config + ? callProp(defaultProps.config, key!) + : void 0 ) } @@ -641,11 +670,11 @@ export class SpringValue extends FrameValue { /** When true, start at the "from" value. */ const reset = - // Can't reset if no "from" value exists. - (props.reset && !is.und(from)) || // We want { from } to imply { reset: true } // unless default values are being set. - (hasFromProp && !props.default) + (hasFromProp && !props.default) || + // Can't reset if no "from" value exists. + (!is.und(from) && matchProp(props.reset, key)) // The current value, where the animation starts from. const value = reset ? (from as T) : this.get() @@ -774,20 +803,11 @@ export class SpringValue extends FrameValue { } if (hasAsyncTo) { - return resolve( - runAsync( - props.to as any, - props, - this._state, - () => this.get(), - () => this.is(PAUSED), - this.start.bind(this), - this.stop.bind(this) as any - ) - ) + resolve(runAsync(props.to, props, this._state, this)) } - if (started) { + // Start an animation + else if (started) { // Unpause the async animation if one exists. this.resume() diff --git a/packages/core/src/helpers.test.ts b/packages/core/src/helpers.test.ts index 08f325e63..b63a2a52e 100644 --- a/packages/core/src/helpers.test.ts +++ b/packages/core/src/helpers.test.ts @@ -19,6 +19,7 @@ describe('helpers', () => { ref: undefined, loop: undefined, reset: undefined, + pause: undefined, cancel: undefined, reverse: undefined, immediate: undefined, diff --git a/packages/core/src/helpers.ts b/packages/core/src/helpers.ts index 3965b8ce2..6fa8fbc2f 100644 --- a/packages/core/src/helpers.ts +++ b/packages/core/src/helpers.ts @@ -8,6 +8,7 @@ import { AnyFn, OneOrMore, FluidValue, + Lookup, } from 'shared' import * as G from 'shared/globals' import { ReservedProps, ForwardProps, InferTo } from './types' @@ -35,6 +36,9 @@ export const matchProp = ( (is.fun(value) ? value(key) : toArray(value).includes(key)) ) +export const concatFn = (first: T | undefined, last: T) => + first ? (...args: Parameters) => (first(...args), last(...args)) : last + type AnyProps = OneOrMore | ((i: number, arg: Arg) => T) export const getProps = ( @@ -45,8 +49,43 @@ export const getProps = ( props && (is.fun(props) ? props(i, arg) : is.arr(props) ? props[i] : { ...props }) +/** Returns `true` if the given prop is having its default value set. */ +export const hasDefaultProp = (props: T, key: keyof T) => + !is.und(getDefaultProp(props, key)) + +/** Get the default value being set for the given `key` */ +export const getDefaultProp = (props: T, key: keyof T) => + props.default === true + ? props[key] + : props.default + ? props.default[key] + : undefined + +export const mergeDefaultProps = ( + defaultProps: Lookup, + props: Lookup & { default?: boolean | Lookup }, + omitKeys: string[] = [] +) => { + if (props.default === true) { + each(DEFAULT_PROPS, key => { + const value = props[key] + if (!is.und(value) && !omitKeys.includes(key)) { + defaultProps[key] = value as any + } + }) + } else if (props.default) { + each(props.default, (value, key) => { + if (!is.und(value) && !omitKeys.includes(key)) { + defaultProps[key] = value as any + } + }) + } +} + /** These props can have default values */ export const DEFAULT_PROPS = [ + 'pause', + 'cancel', 'config', 'immediate', 'onDelayEnd', @@ -64,6 +103,7 @@ const RESERVED_PROPS: Required = { ref: 1, loop: 1, reset: 1, + pause: 1, cancel: 1, reverse: 1, immediate: 1, @@ -136,3 +176,17 @@ export function computeGoal(value: T | FluidValue): T { })(1) as any) : value } + +/** Basic helper for clearing a queue after processing it */ +export function flush( + queue: Map, + iterator: (entry: [P, T]) => void +): void +export function flush(queue: Set, iterator: (value: T) => void): void +export function flush(queue: any, iterator: any) { + if (queue.size) { + const items = Array.from(queue) + queue.clear() + each(items, iterator) + } +} diff --git a/packages/core/src/hooks/useSprings.ts b/packages/core/src/hooks/useSprings.ts index 36850dc55..77e35a0c9 100644 --- a/packages/core/src/hooks/useSprings.ts +++ b/packages/core/src/hooks/useSprings.ts @@ -27,7 +27,7 @@ import { flushUpdateQueue, setSprings, } from '../Controller' -import { useMemo as useMemoOne } from '../helpers' +import { useMemo as useMemoOne, mergeDefaultProps } from '../helpers' import { SpringHandle } from '../SpringHandle' export type UseSpringsProps = unknown & @@ -150,7 +150,16 @@ export function useSprings( if (update) { update = updates[i] = createUpdate(update) - update.default = true + if (!update.default) { + // Declarative updates always set the default props. + const defaultProps: Lookup = (update.default = {}) + mergeDefaultProps(defaultProps, update) + + // Avoid forcing `immediate: true` onto imperative updates. + if (defaultProps.immediate === true) { + defaultProps.immediate = undefined + } + } if (i == 0) { refProp.current = update.ref update.ref = undefined diff --git a/packages/core/src/hooks/useTransition.tsx b/packages/core/src/hooks/useTransition.tsx index c5e6688fe..61e07987a 100644 --- a/packages/core/src/hooks/useTransition.tsx +++ b/packages/core/src/hooks/useTransition.tsx @@ -24,7 +24,7 @@ import { TransitionDefaultProps, } from '../types' import { Valid } from '../types/common' -import { DEFAULT_PROPS, callProp, inferTo } from '../helpers' +import { callProp, inferTo, mergeDefaultProps } from '../helpers' import { Controller, getSprings, setSprings } from '../Controller' import { SpringHandle } from '../SpringHandle' import { @@ -143,11 +143,7 @@ export function useTransition( const forceUpdate = useForceUpdate() const defaultProps = {} as TransitionDefaultProps - each(DEFAULT_PROPS, prop => { - if (/function|object/.test(typeof props[prop])) { - defaultProps[prop] = props[prop] as any - } - }) + mergeDefaultProps(defaultProps, props) // Generate changes to apply in useEffect. const changes = new Map() diff --git a/packages/core/src/runAsync.ts b/packages/core/src/runAsync.ts index c8237dc37..265d3f33a 100644 --- a/packages/core/src/runAsync.ts +++ b/packages/core/src/runAsync.ts @@ -1,33 +1,40 @@ -import { is, each, Pick } from 'shared' +import { is, each, Pick, Timeout } from 'shared' import * as G from 'shared/globals' -import { matchProp, DEFAULT_PROPS, callProp } from './helpers' +import { matchProp, callProp, mergeDefaultProps, concatFn } from './helpers' import { AnimationResolver, ControllerUpdate, SpringChain, SpringDefaultProps, SpringProps, - SpringStopFn, SpringToFn, } from './types' -import { AnimationResult, AsyncResult } from './AnimationResult' +import { + getCancelledResult, + getFinishedResult, + AnimationResult, + AsyncResult, + AnimationTarget, +} from './AnimationResult' export interface RunAsyncProps extends SpringProps { callId: number cancel: boolean - reset: boolean + pause: boolean delay: number to?: any } export interface RunAsyncState { + /** Functions to be called once paused */ + pauseQueue: Set + /** Functions to be called once resumed */ + resumeQueue: Set /** The async function or array of spring props */ asyncTo?: SpringChain | SpringToFn /** Resolves when the current `asyncTo` finishes or gets cancelled. */ promise?: AsyncResult - /** Call this to unpause the current `asyncTo` function or array. */ - unpause?: () => void /** The last time we saw a matching `cancel` prop. */ cancelId?: number } @@ -44,20 +51,15 @@ export async function runAsync( to: SpringChain | SpringToFn, props: RunAsyncProps, state: RunAsyncState, - getValue: () => T, - getPaused: () => boolean, - update: (props: any) => AsyncResult, - stop: SpringStopFn + target: AnimationTarget ): AsyncResult { - if (props.cancel) { - state.asyncTo = undefined - return { - value: getValue(), - cancelled: true, - } + if (props.pause) { + await new Promise(resume => { + state.resumeQueue.add(resume) + }) } // Wait for the previous async animation to be cancelled. - else if (props.reset) { + if (props.reset) { await state.promise } // Async animations are only replaced when "props.to" changes @@ -70,25 +72,24 @@ export async function runAsync( let result!: AnimationResult const defaultProps: SpringDefaultProps = {} - each(DEFAULT_PROPS, prop => { - if (prop == 'onRest') return - if (/function|object/.test(typeof props[prop])) { - defaultProps[prop] = props[prop] as any - } - }) + mergeDefaultProps(defaultProps, props, ['onRest']) const { callId, onRest } = props - - // Note: This function cannot be async, because `checkFailConditions` must be sync. - const animate: any = (arg1: any, arg2?: any) => { + const throwInvalidated = () => { // Prevent further animation if cancelled. if (callId <= (state.cancelId || 0)) { - throw (result = { value: getValue(), cancelled: true }) + throw (result = getCancelledResult(target)) } // Prevent further animation if another "runAsync" call is active. if (to !== state.asyncTo) { - throw (result = { value: getValue(), finished: false }) + throw (result = getFinishedResult(target, false)) } + } + + // Note: This function cannot use the `async` keyword, because we want the + // `throw` statements to interrupt the caller. + const animate: any = (arg1: any, arg2?: any) => { + throwInvalidated() const props: ControllerUpdate = is.obj(arg1) ? { ...arg1 } @@ -101,14 +102,16 @@ export async function runAsync( }) const parentTo = state.asyncTo - return update(props).then(async result => { + return target.start(props).then(async result => { + throwInvalidated() + if (state.asyncTo == null) { state.asyncTo = parentTo } - if (getPaused()) { - state.unpause = await new Promise(resolve => { - state.unpause = resolve + if (target.is('PAUSED')) { + await new Promise(resume => { + state.resumeQueue.add(resume) }) } @@ -125,12 +128,9 @@ export async function runAsync( } // Async script else if (is.fun(to)) { - await to(animate, stop) - } - result = { - value: getValue(), - finished: true, + await to(animate, target.stop.bind(target) as any) } + result = getFinishedResult(target, true) } catch (err) { if (err !== result) { throw err @@ -158,43 +158,76 @@ export function cancelAsync(state: RunAsyncState, callId: number) { } // -// scheduleProps(props, state, action) +// scheduleProps // interface ScheduledProps { key?: string - props: Pick, 'cancel' | 'reset' | 'delay'> - state: { cancelId?: number } - action: (props: RunAsyncProps, resolve: AnimationResolver) => void + props: Pick, 'cancel' | 'pause' | 'delay'> + state: RunAsyncState + actions: { + pause: () => void + start: (props: RunAsyncProps, resolve: AnimationResolver) => void + } } /** - * Pass a copy of the given props to an `action` after any delay is finished - * and the props weren't cancelled before then. + * This function sets a timeout if both the `delay` prop exists and + * the `cancel` prop is not `true`. + * + * The `actions.start` function must handle the `cancel` prop itself, + * but the `pause` prop is taken care of. */ export function scheduleProps( callId: number, - { key, props, state, action }: ScheduledProps + { key, props, state, actions }: ScheduledProps ): AsyncResult { return new Promise((resolve, reject) => { - const delay = Math.max(0, callProp(props.delay || 0, key)) - if (delay > 0) G.frameLoop.setTimeout(run, delay) - else run() + let delay: number + let timeout: Timeout + + let pause = false + let cancel = matchProp(props.cancel, key) + + if (cancel) { + onStart() + } else { + delay = callProp(props.delay || 0, key) + pause = matchProp(props.pause, key) + if (pause) { + state.resumeQueue.add(onResume) + actions.pause() + } else { + onResume() + } + } + + function onPause() { + state.resumeQueue.add(onResume) + timeout.cancel() + // Cache the remaining delay. + delay = timeout.time - G.now() + } + + function onResume() { + if (delay > 0) { + state.pauseQueue.add(onPause) + timeout = G.frameLoop.setTimeout(onStart, delay) + } else { + onStart() + } + } + + function onStart() { + state.pauseQueue.delete(onPause) + + // Maybe cancelled during its delay. + if (callId <= (state.cancelId || 0)) { + cancel = true + } - function run() { - let { cancel, reset } = props try { - // Might have been cancelled during its delay. - if (callId <= (state.cancelId || 0)) { - cancel = true - } else { - cancel = matchProp(cancel, key) - if (cancel) { - state.cancelId = callId - } - } - reset = !cancel && matchProp(reset, key) - action({ ...props, callId, delay, cancel, reset }, resolve) + actions.start({ ...props, callId, delay, cancel, pause }, resolve) } catch (err) { reject(err) } diff --git a/packages/core/src/types/props.ts b/packages/core/src/types/props.ts index 64d2f3752..632529c28 100644 --- a/packages/core/src/types/props.ts +++ b/packages/core/src/types/props.ts @@ -225,6 +225,11 @@ export interface AnimationProps { * or an array of keys. */ cancel?: MatchProp> + /** + * Pause all animations by using `true`, or some animations by using a key + * or an array of keys. + */ + pause?: MatchProp> /** * Start the next animations at their values in the `from` prop. */ @@ -236,7 +241,7 @@ export interface AnimationProps { /** * Override the default props with this update. */ - default?: boolean + default?: boolean | SpringDefaultProps } /** Default props for a `SpringValue` object */ @@ -267,6 +272,7 @@ export interface ReservedProps { to?: any ref?: any loop?: any + pause?: any reset?: any cancel?: any reverse?: any diff --git a/packages/shared/src/FrameLoop.ts b/packages/shared/src/FrameLoop.ts index 3772a564f..3a86093ce 100644 --- a/packages/shared/src/FrameLoop.ts +++ b/packages/shared/src/FrameLoop.ts @@ -14,6 +14,12 @@ export interface OpaqueAnimation { advance(dt: number): void } +export interface Timeout { + time: number + handler: () => void + cancel: () => void +} + /** * FrameLoop executes its animations in order of lowest priority first. * Animations are released once idle. The loop is paused while no animations @@ -38,7 +44,7 @@ export class FrameLoop { * `ms` delay is completed. When the delay is `<= 0`, the handler is * invoked immediately. */ - setTimeout: (handler: Function, ms: number) => void + setTimeout: (handler: () => void, ms: number) => Timeout /** * Execute a function once after all animations have updated. @@ -110,18 +116,23 @@ export class FrameLoop { } } - interface Timeout { - time: number - handler: Function - } - const timeoutQueue: Timeout[] = [] this.setTimeout = (handler, ms) => { const time = G.now() + ms + const cancel = () => { + const index = timeoutQueue.findIndex(t => t.cancel == cancel) + if (index >= 0) { + timeoutQueue.splice(index, 1) + } + } + const index = findIndex(timeoutQueue, t => t.time > time) - timeoutQueue.splice(index, 0, { time, handler }) + const timeout = { time, handler, cancel } + timeoutQueue.splice(index, 0, timeout) + kickoff() + return timeout } // Process the current frame.