diff --git a/src/animated/AnimatedInterpolation.ts b/src/animated/AnimatedInterpolation.ts index c4dac2b037..96bd70531d 100644 --- a/src/animated/AnimatedInterpolation.ts +++ b/src/animated/AnimatedInterpolation.ts @@ -43,6 +43,6 @@ export default class AnimatedInterpolation extends AnimatedArray range: number[] | InterpolationConfig | ((...args: any[]) => IpValue), output?: (number | string)[] ): AnimatedInterpolation { - return new AnimatedInterpolation(this, range as number[], output!) + return new AnimatedInterpolation(this, range as number[], output) } } diff --git a/src/animated/AnimatedValue.ts b/src/animated/AnimatedValue.ts index 376b04c273..d48d913ef6 100644 --- a/src/animated/AnimatedValue.ts +++ b/src/animated/AnimatedValue.ts @@ -3,6 +3,7 @@ import { InterpolationConfig } from '../types/interpolation' import Animated from './Animated' import AnimatedInterpolation from './AnimatedInterpolation' import AnimatedProps from './AnimatedProps' +import { now } from './Globals' /** * Animated works by building a directed acyclic graph of dependencies @@ -82,4 +83,14 @@ export default class AnimatedValue extends Animated implements SpringValue { ): AnimatedInterpolation { return new AnimatedInterpolation(this, range as number[], output!) } + + public reset(isActive: boolean) { + this.startPosition = this.value + this.lastPosition = this.value + this.lastVelocity = isActive ? this.lastVelocity : undefined + this.lastTime = isActive ? this.lastTime : undefined + this.startTime = now() + this.done = false + this.animatedStyles.clear() + } } diff --git a/src/animated/AnimatedValueArray.ts b/src/animated/AnimatedValueArray.ts index 81a5f4d24e..71311d7eb9 100644 --- a/src/animated/AnimatedValueArray.ts +++ b/src/animated/AnimatedValueArray.ts @@ -6,9 +6,9 @@ import AnimatedValue from './AnimatedValue' export default class AnimatedValueArray extends AnimatedArray implements SpringValue { - constructor(values: (string | number)[]) { + constructor(values: AnimatedValue[]) { super() - this.payload = values.map(n => new AnimatedValue(n)) + this.payload = values } public setValue(value: (string | number)[] | string | number, flush = true) { @@ -29,6 +29,6 @@ export default class AnimatedValueArray extends AnimatedArray range: number[] | InterpolationConfig | ((...args: any[]) => any), output?: (number | string)[] ): AnimatedInterpolation { - return new AnimatedInterpolation(this, range as number[], output!) + return new AnimatedInterpolation(this, range as number[], output) } } diff --git a/src/animated/Controller.ts b/src/animated/Controller.ts index 0f1da8dc7c..c109c35e3b 100644 --- a/src/animated/Controller.ts +++ b/src/animated/Controller.ts @@ -7,373 +7,569 @@ import { } from '../shared/helpers' import AnimatedValue from './AnimatedValue' import AnimatedValueArray from './AnimatedValueArray' +import AnimatedInterpolation from './AnimatedInterpolation' import { start, stop } from './FrameLoop' import { colorNames, interpolation as interp, now } from './Globals' +import { SpringProps, SpringConfig } from '../../types/renderprops' + +type Omit = Pick> + +interface Animation extends Omit { + key: string + config: SpringConfig + initialVelocity: number + immediate?: boolean + goalValue: T + toValues: T extends ReadonlyArray ? T : [T] + fromValues: T extends ReadonlyArray ? T : [T] + animatedValues: AnimatedValue[] + animated: T extends ReadonlyArray + ? AnimatedValueArray + : AnimatedValue | AnimatedInterpolation +} -type FinishedCallback = (finished?: boolean) => void +type AnimationMap = { [key: string]: Animation } +type AnimatedMap = { [key: string]: Animation['animated'] } -type AnimationsFor

= { [Key in keyof P]: any } +interface UpdateProps extends SpringProps { + [key: string]: any + timestamp?: number + attach?: (ctrl: Controller) => Controller +} -type ValuesFor

= { [Key in keyof P]: any } +type OnEnd = (finished?: boolean) => void -type InterpolationsFor

= { - [Key in keyof P]: P[Key] extends ArrayLike - ? AnimatedValueArray - : AnimatedValue -} +// Default easing +const linear = (t: number) => t -let G = 0 -class Controller

{ - id: number +const emptyObj: any = Object.freeze({}) +let nextId = 1 +class Controller { + id = nextId++ idle = true - hasChanged = false - guid = 0 - local = 0 - props: P = {} as P - merged: any = {} - animations = {} as AnimationsFor

- interpolations = {} as InterpolationsFor

- values = {} as ValuesFor

- configs: any = [] - listeners: FinishedCallback[] = [] + props: UpdateProps = {} queue: any[] = [] - localQueue?: any[] - - constructor() { - this.id = G++ + timestamps: { [key: string]: number } = {} + values: State = {} as any + merged: State = {} as any + animated: AnimatedMap = {} + animations: AnimationMap = {} + configs: Animation[] = [] + onEndQueue: OnEnd[] = [] + runCount = 0 + + getValues = () => + this.animated as { [K in keyof State]: Animation['animated'] } + + constructor(props?: UpdateProps) { + if (props) this.update(props).start() } - /** update(props) - * This function filters input props and creates an array of tasks which are executed in .start() - * Each task is allowed to carry a delay, which means it can execute asnychroneously */ - update(args?: P) { - //this._id = n + this.id - - if (!args) return this - // Extract delay and the to-prop from props - const { delay = 0, to, ...props } = interpolateTo(args) as any - if (is.arr(to) || is.fun(to)) { - // If config is either a function or an array queue it up as is - this.queue.push({ ...props, delay, to }) - } else if (to) { - // Otherwise go through each key since it could be delayed individually - let ops: any = {} - Object.entries(to).forEach(([k, v]) => { - // Fetch delay and create an entry, consisting of the to-props, the delay, and basic props - const entry = { to: { [k]: v }, delay: callProp(delay, k), ...props } - const previous = ops[entry.delay] && ops[entry.delay].to - ops[entry.delay] = { - ...ops[entry.delay], - ...entry, - to: { ...previous, ...entry.to }, - } - }) - this.queue = Object.values(ops) + /** + * Push props into the update queue. The props are used after `start` is + * called and any delay is over. The props are intelligently diffed to ensure + * that later calls to this method properly override any delayed props. + * The `propsArg` argument is always copied before mutations are made. + */ + update(propsArg: UpdateProps) { + if (!propsArg) return this + const props = interpolateTo(propsArg) as any + props.timestamp = now() + + // For async animations, the `from` prop must be defined for + // the Animated nodes to exist before animations have started. + this._ensureAnimated(props.from) + if (is.obj(props.to)) { + this._ensureAnimated(props.to) } - // Sort queue, so that async calls go last - this.queue = this.queue.sort((a, b) => a.delay - b.delay) - // Diff the reduced props immediately (they'll contain the from-prop and some config) - this.diff(props) + // The `delay` prop of every update must be a number >= 0 + if (is.fun(props.delay) && is.obj(props.to)) { + for (const key in props.to) { + this.queue.push({ + ...props, + to: { [key]: props.to[key] }, + from: key in props.from ? { [key]: props.from[key] } : void 0, + delay: Math.max(0, Math.round(props.delay(key))), + }) + } + } else { + props.delay = is.num(props.delay) + ? Math.max(0, Math.round(props.delay)) + : 0 + this.queue.push(props) + } return this } - /** start(onEnd) - * This function either executes a queue, if present, or starts the frameloop, which animates */ - start(onEnd?: FinishedCallback) { - // If a queue is present we must excecute it - if (this.queue.length) { - this.idle = false + /** + * Flush the update queue. + * If the queue is empty, try starting the frameloop. + */ + start(onEnd?: OnEnd) { + if (this.queue.length) this._flush(onEnd) + else this._start(onEnd) + return this + } - // Updates can interrupt trailing queues, in that case we just merge values - if (this.localQueue) { - this.localQueue.forEach(({ from = {}, to = {} }) => { - if (is.obj(from)) this.merged = { ...from, ...this.merged } - if (is.obj(to)) this.merged = { ...this.merged, ...to } - }) + /** Stop one animation or all animations */ + stop(...keys: string[]): this + stop(finished: boolean, ...keys: string[]): this + stop(...keys: [boolean, ...any[]] | string[]) { + let finished = false + if (is.boo(keys[0])) [finished, ...keys] = keys + + // Stop animations by key + if (keys.length) { + for (const key of keys) { + const index = this.configs.findIndex(config => key === config.key) + this._stopAnimation(key) + this.configs[index] = this.animations[key] } + } + // Stop all animations + else if (this.runCount) { + // Stop all async animations + this.animations = { ...this.animations } - // The guid helps us tracking frames, a new queue over an old one means an override - // We discard async calls in that caseƍ - const local = (this.local = ++this.guid) - const queue = (this.localQueue = this.queue) - this.queue = [] - - // Go through each entry and execute it - queue.forEach(({ delay, ...props }, index) => { - const cb: FinishedCallback = finished => { - if (index === queue.length - 1 && local === this.guid && finished) { - this.idle = true - if (this.props.onRest) this.props.onRest(this.merged) - } - if (onEnd) onEnd() - } + // Update the animation configs + this.configs.forEach(config => this._stopAnimation(config.key)) + this.configs = Object.values(this.animations) - // Entries can be delayed, ansyc or immediate - let async = is.arr(props.to) || is.fun(props.to) - if (delay) { - setTimeout(() => { - if (local === this.guid) { - if (async) this.runAsync(props, cb) - else this.diff(props).start(cb) - } - }, delay) - } else if (async) this.runAsync(props, cb) - else this.diff(props).start(cb) - }) - } - // Otherwise we kick of the frameloop - else { - if (is.fun(onEnd)) this.listeners.push(onEnd) - if (this.props.onStart) this.props.onStart() - start(this) + // Exit the frameloop + this._stop(finished) } return this } - stop(finished?: boolean) { - this.listeners.forEach(onEnd => onEnd(finished)) - this.listeners = [] - return this + /** @internal Called by the frameloop */ + onFrame(isActive: boolean) { + if (this.props.onFrame) { + this.props.onFrame(this.values) + } + if (!isActive) { + this._stop(true) + } } - /** Pause sets onEnd listeners free, but also removes the controller from the frameloop */ - pause(finished?: boolean) { - this.stop(true) - if (finished) stop(this) - return this + /** Reset the internal state */ + destroy() { + this.stop() + this.props = {} + this.timestamps = {} + this.values = {} as any + this.merged = {} as any + this.animated = {} + this.animations = {} + this.configs = [] } - runAsync({ delay, ...props }: P, onEnd: FinishedCallback) { - const local = this.local - // If "to" is either a function or an array it will be processed async, therefor "to" should be empty right now - // If the view relies on certain values "from" has to be present - let queue = Promise.resolve(undefined) - if (is.arr(props.to)) { - for (let i = 0; i < props.to.length; i++) { - const index = i - const fresh = { ...props, ...interpolateTo(props.to[index]) } - if (is.arr(fresh.config)) fresh.config = fresh.config[index] - queue = queue.then( - (): Promise | void => { - //this.stop() - if (local === this.guid) - return new Promise(r => this.diff(fresh).start(r)) - } - ) + // Create an Animated node if none exists. + private _ensureAnimated(values: any) { + for (const key in values) { + if (this.animated[key]) continue + const value = values[key] + const animated = createAnimated(value) + if (animated) { + this.animated[key] = animated + this._stopAnimation(key) + } else { + console.warn('Given value not animatable:', value) + } + } + } + + // Listen for all animations to end. + private _onEnd(onEnd: OnEnd) { + if (this.runCount) this.onEndQueue.push(onEnd) + else onEnd(true) + } + + // Add this controller to the frameloop. + private _start(onEnd?: OnEnd) { + if (onEnd) this._onEnd(onEnd) + if (this.idle && this.runCount) { + this.idle = false + start(this) + } + } + + // Remove this controller from the frameloop, and notify any listeners. + private _stop(finished?: boolean) { + this.idle = true + stop(this) + + const { onEndQueue } = this + if (onEndQueue.length) { + this.onEndQueue = [] + onEndQueue.forEach(onEnd => onEnd(finished)) + } + } + + // Execute the current queue of prop updates. + private _flush(onEnd?: OnEnd) { + const queue = this.queue.reduce(reduceDelays, []) + this.queue.length = 0 + + // Track the number of running animations. + let runsLeft = Object.keys(queue).length + this.runCount += runsLeft + + // Never assume that the last update always finishes last, since that's + // not true when 2+ async updates have indeterminate durations. + const onRunEnd = (finished?: boolean) => { + this.runCount-- + if (--runsLeft) return + if (onEnd) onEnd(finished) + if (!this.runCount && finished) { + const { onRest } = this.props + if (onRest) onRest(this.merged) } - } else if (is.fun(props.to)) { - let index = 0 - let last: Promise + } + + queue.forEach((props, delay) => { + if (delay) setTimeout(() => this._run(props, onRunEnd), delay) + else this._run(props, onRunEnd) + }) + } + + // Update the props and animations + private _run(props: UpdateProps, onEnd: OnEnd) { + if (is.arr(props.to) || is.fun(props.to)) { + this._runAsync(props, onEnd) + } else if (this._diff(props)) { + this._animate(this.props)._start(onEnd) + } else { + this._onEnd(onEnd) + } + } + + // Start an async chain or an async script. + private _runAsync({ to, ...props }: UpdateProps, onEnd: OnEnd) { + // Merge other props immediately. + if (this._diff(props)) { + this._animate(this.props) + } + + // This async animation might be overridden. + if (!this._diff({ asyncTo: to, timestamp: props.timestamp })) { + return onEnd(false) + } + + // Async chains run to completion. Async scripts are interrupted. + const { animations } = this + const isCancelled = () => + // The `stop` and `destroy` methods clear the animation map. + animations !== this.animations || + // Async scripts are cancelled when a new chain/script begins. + (is.fun(to) && to !== this.props.asyncTo) + + let last: Promise + const next = (props: UpdateProps) => { + if (isCancelled()) throw this + return (last = new Promise(done => { + this.update(props).start(done) + })).then(() => { + if (isCancelled()) throw this + }) + } + + let queue = Promise.resolve() + if (is.arr(to)) { + to.forEach(props => (queue = queue.then(() => next(props)))) + } else if (is.fun(to)) { queue = queue.then(() => - props - .to( - // next(props) - (p: P) => { - const fresh = { ...props, ...interpolateTo(p) } - if (is.arr(fresh.config)) fresh.config = fresh.config[index] - index++ - //this.stop() - if (local === this.guid) - return (last = new Promise(r => this.diff(fresh).start(r))) - return - }, - // cancel() - (finished = true) => this.stop(finished) - ) + to(next, this.stop.bind(this)) + // Always wait for the last update. .then(() => last) ) } - queue.then(onEnd) + + queue + .catch(err => err !== this && console.error(err)) + .then(() => onEnd(!isCancelled())) } - diff(props: any) { - this.props = { ...this.props, ...props } - let { - from = {}, - to = {}, - config = {}, - reverse, - attach, - reset, - immediate, - } = this.props + // Merge every fresh prop. Returns true if one or more props changed. + private _diff({ timestamp, config, ...props }: UpdateProps) { + let changed = false - // Reverse values when requested - if (reverse) { - ;[from, to] = [to, from] + // Ensure the newer timestamp is used. + const diffTimestamp = (keyPath: string) => { + const previous = this.timestamps[keyPath] + if (is.und(previous) || timestamp! > previous) { + this.timestamps[keyPath] = timestamp! + return true + } + return false + } + + // Generalized diffing algorithm + const diffProp = (keys: string[], value: any, parent: any) => { + if (is.und(value)) return + const lastKey = keys[keys.length - 1] + if (is.obj(value)) { + if (!is.obj(parent[lastKey])) parent[lastKey] = {} + for (const key in value) { + diffProp(keys.concat(key), value[key], parent[lastKey]) + } + } else if (diffTimestamp(keys.join('.'))) { + const oldValue = parent[lastKey] + if (!is.equ(value, oldValue)) { + changed = true + parent[lastKey] = value + } + } } - // This will collect all props that were ever set, reset merged props when necessary - this.merged = { ...from, ...this.merged, ...to } + // The `config` prop is atomic + if (config && diffTimestamp('config')) { + changed = true + this.props.config = config + } + + for (const key in props) { + diffProp([key], props[key], this.props) + } + return changed + } + + // Update the animation configs. + private _animate(props: UpdateProps) { + let { to = emptyObj, from = emptyObj } = props + + // Reverse values when requested + if (props.reverse) [from, to] = [to, from] + + // Merge `from` values with `to` values + this.merged = { ...from, ...to } + + // True if any animation was updated + let changed = false + + // The animations that are starting or restarting + const started: string[] = [] - this.hasChanged = false // Attachment handling, trailed springs can "attach" themselves to a previous spring - let target = attach && attach(this) - // Reduces input { name: value } pairs into animated values - this.animations = Object.entries(this.merged).reduce( - (acc, [name, value]) => { - // Issue cached entries, except on reset - let entry = acc[name] || {} - - // Figure out what the value is supposed to be - const isNumber = is.num(value) - const isString = - is.str(value) && - !value.startsWith('#') && - !/\d/.test(value) && - !colorNames[value] - const isArray = is.arr(value) - const isInterpolation = !isNumber && !isArray && !isString - - let fromValue = !is.und(from[name]) ? from[name] : value - let toValue = isNumber || isArray ? value : isString ? value : 1 - let toConfig = callProp(config, name) - if (target) toValue = target.animations[name].parent - - let parent = entry.parent, - interpolation = entry.interpolation, - toValues = toArray(target ? toValue.getPayload() : toValue), - animatedValues - - let newValue = value - if (isInterpolation) - newValue = interp({ - range: [0, 1], - output: [value as string, value as string], - })(1) - let currentValue = interpolation && interpolation.getValue() - - // Change detection flags - const isFirst = is.und(parent) - const isActive = - !isFirst && entry.animatedValues.some((v: AnimatedValue) => !v.done) - const currentValueDiffersFromGoal = !is.equ(newValue, currentValue) - const hasNewGoal = !is.equ(newValue, entry.previous) - const hasNewConfig = !is.equ(toConfig, entry.config) - - // Change animation props when props indicate a new goal (new value differs from previous one) - // and current values differ from it. Config changes trigger a new update as well (though probably shouldn't?) - if ( - reset || - (hasNewGoal && currentValueDiffersFromGoal) || - hasNewConfig - ) { - // Convert regular values into animated values, ALWAYS re-use if possible - if (isNumber || isString) - parent = interpolation = - entry.parent || new AnimatedValue(fromValue) - else if (isArray) - parent = interpolation = - entry.parent || new AnimatedValueArray(fromValue) - else if (isInterpolation) { - let prev = - entry.interpolation && - entry.interpolation.calc(entry.parent.value) - prev = prev !== void 0 && !reset ? prev : fromValue - if (entry.parent) { - parent = entry.parent - parent.setValue(0, false) - } else parent = new AnimatedValue(0) - const range = { output: [prev, value] } - if (entry.interpolation) { - interpolation = entry.interpolation - entry.interpolation.updateConfig(range) - } else interpolation = parent.interpolate(range) - } + const target = props.attach && props.attach(this) + + // Reduces input { key: value } pairs into animation objects + for (const key in this.merged) { + const state = this.animations[key] + if (!state) { + console.warn( + `Failed to animate key: "${key}"\n` + + `Did you forget to define "from.${key}" for an async animation?` + ) + continue + } - toValues = toArray(target ? toValue.getPayload() : toValue) - animatedValues = toArray(parent.getPayload()) - if (reset && !isInterpolation) parent.setValue(fromValue, false) - - this.hasChanged = true - // Reset animated values - animatedValues.forEach(value => { - value.startPosition = value.value - value.lastPosition = value.value - value.lastVelocity = isActive ? value.lastVelocity : undefined - value.lastTime = isActive ? value.lastTime : undefined - value.startTime = now() - value.done = false - value.animatedStyles.clear() - }) - - // Set immediate values - if (callProp(immediate, name)) { - parent.setValue(isInterpolation ? toValue : value, false) - } + // Reuse the Animated nodes whenever possible + let { animated, animatedValues } = state + + const value = this.merged[key] + const goalValue = computeGoalValue(value) - return { - ...acc, - [name]: { - ...entry, - name, - parent, - interpolation, - animatedValues, - toValues, - previous: newValue, - config: toConfig, - fromValues: toArray(parent.getValue()), - immediate: callProp(immediate, name), - initialVelocity: withDefault(toConfig.velocity, 0), - clamp: withDefault(toConfig.clamp, false), - precision: withDefault(toConfig.precision, 0.01), - tension: withDefault(toConfig.tension, 170), - friction: withDefault(toConfig.friction, 26), - mass: withDefault(toConfig.mass, 1), - duration: toConfig.duration, - easing: withDefault(toConfig.easing, (t: number) => t), - decay: toConfig.decay, - }, + // Stop animations with a goal value equal to its current value. + if (!props.reset && is.equ(goalValue, animated.getValue())) { + // The animation might be stopped already. + if (!is.und(state.goalValue)) { + changed = true + this._stopAnimation(key) + } + continue + } + + const config = callProp(props.config, key) || emptyObj + + // Animations are only updated when they were reset, they have a new + // goal value, or their spring config was changed. + if ( + props.reset || + !is.equ(goalValue, state.goalValue) || + !is.equ(config, state.config) + ) { + const immediate = callProp(props.immediate, key) + if (!immediate) started.push(key) + + const isActive = animatedValues.some(v => !v.done) + const fromValue = !is.und(from[key]) + ? computeGoalValue(from[key]) + : goalValue + + // Animatable strings use interpolation + const isInterpolated = isAnimatableString(value) + if (isInterpolated) { + let input: AnimatedValue + const output: any[] = [fromValue, goalValue] + if (animated instanceof AnimatedInterpolation) { + input = animatedValues[0] + + if (!props.reset) output[0] = animated.calc(input.value) + animated.updateConfig({ output }) + + input.setValue(0, false) + input.reset(isActive) + } else { + input = new AnimatedValue(0) + animated = input.interpolate({ output }) + } + if (immediate) { + input.setValue(1, false) } } else { - if (!currentValueDiffersFromGoal) { - // So ... the current target value (newValue) appears to be different from the previous value, - // which normally constitutes an update, but the actual value (currentValue) matches the target! - // In order to resolve this without causing an animation update we silently flag the animation as done, - // which it technically is. Interpolations also needs a config update with their target set to 1. - if (isInterpolation) { - parent.setValue(1, false) - interpolation.updateConfig({ output: [newValue, newValue] }) + // Convert values into Animated nodes (reusing nodes whenever possible) + if (is.arr(value)) { + if (animated instanceof AnimatedValueArray) { + animatedValues.forEach(v => v.reset(isActive)) + } else { + animated = createAnimated(fromValue) } - - parent.done = true - this.hasChanged = true - return { ...acc, [name]: { ...acc[name], previous: newValue } } + } else { + if (animated instanceof AnimatedValue) { + animated.reset(isActive) + } else { + animated = new AnimatedValue(fromValue) + } + } + if (props.reset || immediate) { + animated.setValue(immediate ? goalValue : fromValue, false) } - return acc } - }, - this.animations - ) - if (this.hasChanged) { - // Make animations available to frameloop - this.configs = Object.values(this.animations) - this.values = {} as ValuesFor

- this.interpolations = {} as InterpolationsFor

- for (let key in this.animations) { - this.interpolations[key] = this.animations[key].interpolation - this.values[key] = this.animations[key].interpolation.getValue() + // Update the array of Animated nodes used by the frameloop + animatedValues = toArray(animated.getPayload() as any) + + changed = true + this.animations[key] = { + key, + goalValue, + toValues: toArray( + target + ? target.animations[key].animated.getPayload() + : (isInterpolated && 1) || goalValue + ), + fromValues: animatedValues.map(v => v.getValue()), + animated, + animatedValues, + immediate, + duration: config.duration, + easing: withDefault(config.easing, linear), + decay: config.decay, + mass: withDefault(config.mass, 1), + tension: withDefault(config.tension, 170), + friction: withDefault(config.friction, 26), + initialVelocity: withDefault(config.velocity, 0), + clamp: withDefault(config.clamp, false), + precision: withDefault(config.precision, 0.01), + config, + } + } + } + + if (changed) { + if (props.onStart && started.length) { + started.forEach(key => props.onStart!(this.animations[key])) + } + + // Reset any flags + props.reset = false + props.immediate = false + + // Make animations available to the frameloop + const configs = (this.configs = [] as Animation[]) + const values = (this.values = {} as any) + const nodes = (this.animated = {} as any) + for (const key in this.animations) { + const config = this.animations[key] + configs.push(config) + values[key] = config.animated.getValue() + nodes[key] = config.animated } } return this } - destroy() { - this.stop() - this.props = {} as P - this.merged = {} - this.animations = {} as AnimationsFor

- this.interpolations = {} as InterpolationsFor

- this.values = {} as ValuesFor

- this.configs = [] - this.local = 0 - } + // Stop an animation by its key + private _stopAnimation(key: string) { + if (!this.animated[key]) return + + const state = this.animations[key] + if (state && is.und(state.goalValue)) return + + let { animated, animatedValues } = state || emptyObj + if (!state) { + animated = this.animated[key] + animatedValues = toArray(animated.getPayload() as any) + } + + this.animations[key] = { key, animated, animatedValues } as any + animatedValues.forEach(v => (v.done = true)) - getValues = () => this.interpolations + // Prevent delayed updates to this key. + this.timestamps['to.' + key] = now() + } } export default Controller + +// Wrap any value with an Animated node +function createAnimated( + value: T +): T extends ReadonlyArray + ? AnimatedValueArray + : AnimatedValue | AnimatedInterpolation | null { + return is.arr(value) + ? new AnimatedValueArray( + value.map(fromValue => { + const animated = createAnimated(fromValue)! + if (!animated) { + console.warn('Given value not animatable:', fromValue) + } + return animated instanceof AnimatedValue + ? animated + : (animated.getPayload() as any) + }) + ) + : isAnimatableString(value) + ? (new AnimatedValue(0).interpolate({ + output: [value, value] as any, + }) as any) + : is.num(value) || is.str(value) + ? new AnimatedValue(value) + : null +} + +// Merge updates with the same delay. +// NOTE: Mutation can occur! +function reduceDelays(merged: any[], props: any) { + const prev = merged[props.delay] + if (prev) { + props.to = merge(prev.to, props.to) + props.from = merge(prev.from, props.from) + Object.assign(prev, props) + } else { + merged[props.delay] = props + } + return merged +} + +function merge(dest: any, src: any) { + return is.obj(dest) && is.obj(src) ? { ...dest, ...src } : src || dest +} + +// Not all strings can be animated (eg: {display: "none"}) +function isAnimatableString(value: unknown): boolean { + if (!is.str(value)) return false + return value.startsWith('#') || /\d/.test(value) || !!colorNames[value] +} + +// Compute the goal value, converting "red" to "rgba(255, 0, 0, 1)" in the process +function computeGoalValue(value: T): T { + return is.arr(value) + ? value.map(computeGoalValue) + : isAnimatableString(value) + ? (interp as any)({ range: [0, 1], output: [value, value] })(1) + : value +} diff --git a/src/animated/FrameLoop.ts b/src/animated/FrameLoop.ts index ebfaeee55f..76b5166d8a 100644 --- a/src/animated/FrameLoop.ts +++ b/src/animated/FrameLoop.ts @@ -19,55 +19,55 @@ const update = () => { let config = controller.configs[configIdx] let endOfAnimation, lastTime for (let valIdx = 0; valIdx < config.animatedValues.length; valIdx++) { - let animation = config.animatedValues[valIdx] + let animated = config.animatedValues[valIdx] + if (animated.done) continue - // If an animation is done, skip, until all of them conclude - if (animation.done) continue - - let from = config.fromValues[valIdx] let to = config.toValues[valIdx] - let position = animation.lastPosition let isAnimated = to instanceof Animated - let velocity = Array.isArray(config.initialVelocity) - ? config.initialVelocity[valIdx] - : config.initialVelocity if (isAnimated) to = to.getValue() - // Conclude animation if it's either immediate, or from-values match end-state + // Jump to end value for immediate animations if (config.immediate) { - animation.setValue(to) - animation.done = true + animated.setValue(to) + animated.done = true continue } + let from = config.fromValues[valIdx] + // Break animation when string values are involved if (typeof from === 'string' || typeof to === 'string') { - animation.setValue(to) - animation.done = true + animated.setValue(to) + animated.done = true continue } + let position = animated.lastPosition + let velocity = Array.isArray(config.initialVelocity) + ? config.initialVelocity[valIdx] + : config.initialVelocity + if (config.duration !== void 0) { /** Duration easing */ position = from + - config.easing((time - animation.startTime) / config.duration) * + config.easing((time - animated.startTime) / config.duration) * (to - from) - endOfAnimation = time >= animation.startTime + config.duration + endOfAnimation = time >= animated.startTime + config.duration } else if (config.decay) { /** Decay easing */ position = from + (velocity / (1 - 0.998)) * - (1 - Math.exp(-(1 - 0.998) * (time - animation.startTime))) - endOfAnimation = Math.abs(animation.lastPosition - position) < 0.1 + (1 - Math.exp(-(1 - 0.998) * (time - animated.startTime))) + endOfAnimation = Math.abs(animated.lastPosition - position) < 0.1 if (endOfAnimation) to = position } else { /** Spring easing */ - lastTime = animation.lastTime !== void 0 ? animation.lastTime : time + lastTime = animated.lastTime !== void 0 ? animated.lastTime : time velocity = - animation.lastVelocity !== void 0 - ? animation.lastVelocity + animated.lastVelocity !== void 0 + ? animated.lastVelocity : config.initialVelocity // If we lost a lot of frames just jump to the end. @@ -95,8 +95,8 @@ const update = () => { ? Math.abs(to - position) <= config.precision : true endOfAnimation = isOvershooting || (isVelocity && isDisplacement) - animation.lastVelocity = velocity - animation.lastTime = time + animated.lastVelocity = velocity + animated.lastTime = time } // Trails aren't done until their parents conclude @@ -104,26 +104,21 @@ const update = () => { if (endOfAnimation) { // Ensure that we end up with a round value - if (animation.value !== to) position = to - animation.done = true + if (animated.value !== to) position = to + animated.done = true } else isActive = true - animation.setValue(position) - animation.lastPosition = position + animated.setValue(position) + animated.lastPosition = position } // Keep track of updated values only when necessary - if (controller.props.onFrame) - controller.values[config.name] = config.interpolation.getValue() + if (controller.props.onFrame) { + controller.values[config.name] = config.animated.getValue() + } } - // Update callbacks in the end of the frame - if (controller.props.onFrame) controller.props.onFrame(controller.values) - // Either call onEnd or next frame - if (!isActive) { - controllers.delete(controller) - controller.stop(true) - } + controller.onFrame(isActive) } // Loop over as long as there are controllers ... @@ -137,7 +132,7 @@ const update = () => { } const start = (controller: Controller) => { - if (!controllers.has(controller)) controllers.add(controller) + controllers.add(controller) if (!active) { active = true if (manualFrameloop) requestFrame(manualFrameloop) @@ -146,7 +141,7 @@ const start = (controller: Controller) => { } const stop = (controller: Controller) => { - if (controllers.has(controller)) controllers.delete(controller) + controllers.delete(controller) } export { start, stop, update } diff --git a/src/animated/Globals.ts b/src/animated/Globals.ts index aa458b8709..3b0bd8bba5 100644 --- a/src/animated/Globals.ts +++ b/src/animated/Globals.ts @@ -32,7 +32,7 @@ export function injectFrame(raf: typeof requestFrame, caf: typeof cancelFrame) { export let interpolation: ( config: InterpolationConfig -) => (input: number) => string +) => (input: number) => number | string export function injectStringInterpolator(fn: typeof interpolation) { interpolation = fn } diff --git a/src/shared/helpers.ts b/src/shared/helpers.ts index c47e3755f7..985624834a 100644 --- a/src/shared/helpers.ts +++ b/src/shared/helpers.ts @@ -2,13 +2,14 @@ import { MutableRefObject, Ref, useCallback, useState } from 'react' export const is = { arr: Array.isArray, - obj: (a: unknown): a is object => + obj: (a: unknown): a is Object => Object.prototype.toString.call(a) === '[object Object]', fun: (a: unknown): a is Function => typeof a === 'function', str: (a: unknown): a is string => typeof a === 'string', num: (a: unknown): a is number => typeof a === 'number', und: (a: unknown): a is undefined => a === void 0, nul: (a: unknown): a is null => a === null, + boo: (a: unknown): a is boolean => typeof a === 'boolean', set: (a: unknown): a is Set => a instanceof Set, map: (a: unknown): a is Map => a instanceof Map, equ(a: any, b: any) { @@ -48,7 +49,7 @@ export function useForceUpdate() { } export function withDefault(value: T, defaultValue: DT) { - return is.und(value) || is.nul(value) ? defaultValue : value + return is.und(value) || is.nul(value) ? defaultValue : value! } export function toArray(a?: T | T[]): T[] { @@ -120,14 +121,17 @@ interface InterpolateTo extends PartialExcludedProps { export function interpolateTo( props: T ): InterpolateTo { - const forward: ForwardedProps = getForwardProps(props) - if (is.und(forward)) return { to: forward, ...props } - const rest = Object.keys(props).reduce( - (a: PartialExcludedProps, k: string) => - !is.und((forward as any)[k]) ? a : { ...a, [k]: (props as any)[k] }, - {} + const forward = getForwardProps(props) + props = Object.entries(props).reduce( + (props, [key, value]) => (key in forward || (props[key] = value), props), + {} as any ) - return { to: forward, ...rest } + return { to: forward, ...props } +} + +export function isEmptyObj(obj: object) { + for (const _ in obj) return false + return true } export function handleRef(ref: T, forward: Ref) { @@ -141,3 +145,9 @@ export function handleRef(ref: T, forward: Ref) { } return ref } + +export function fillArray(length: number, mapIndex: (index: number) => T) { + const arr = [] + for (let i = 0; i < length; i++) arr.push(mapIndex(i)) + return arr +} diff --git a/src/useChain.js b/src/useChain.js index 24d3f7860c..84e6d6ffa7 100644 --- a/src/useChain.js +++ b/src/useChain.js @@ -17,7 +17,7 @@ export function useChain(refs, timeSteps, timeFrame = 1000) { if (ctrls.length) { const t = timeFrame * timeSteps[index] ctrls.forEach(ctrl => { - ctrl.queue = ctrl.queue.map(e => ({ ...e, delay: e.delay + t })) + ctrl.queue.forEach(props => (props.delay += t)) ctrl.start() }) } diff --git a/src/useSprings.js b/src/useSprings.js index 4bd141770e..d0b38eeaf3 100644 --- a/src/useSprings.js +++ b/src/useSprings.js @@ -1,6 +1,6 @@ import { useMemo, useRef, useImperativeHandle, useEffect } from 'react' import Ctrl from './animated/Controller' -import { callProp, is } from './shared/helpers' +import { callProp, fillArray, is, toArray } from './shared/helpers' /** API * const props = useSprings(number, [{ ... }, { ... }, ...]) @@ -13,70 +13,65 @@ export const useSprings = (length, props) => { const isFn = is.fun(props) // The controller maintains the animation values, starts and stops animations - const [controllers, ref] = useMemo(() => { - // Remove old controllers - if (ctrl.current) { - ctrl.current.map(c => c.destroy()) - ctrl.current = undefined - } - let ref + const [controllers, setProps, ref, api] = useMemo(() => { + let ref, controllers return [ - new Array(length).fill().map((_, i) => { - const ctrl = new Ctrl() - const newProps = isFn ? callProp(props, i, ctrl) : props[i] + // Recreate the controllers whenever `length` changes + (controllers = fillArray(length, i => { + const c = new Ctrl() + const newProps = isFn ? callProp(props, i, c) : props[i] if (i === 0) ref = newProps.ref - ctrl.update(newProps) - if (!ref) ctrl.start() - return ctrl - }), + return c.update(newProps) + })), + // This updates the controllers with new props + props => { + const isFn = is.fun(props) + if (!isFn) props = toArray(props) + controllers.forEach((c, i) => { + c.update(isFn ? callProp(props, i, c) : props[i]) + if (!ref) c.start() + }) + }, + // The imperative API is accessed via ref ref, + ref && { + start: () => + Promise.all(controllers.map(c => new Promise(r => c.start(r)))), + stop: finished => controllers.forEach(c => c.stop(finished)), + controllers, + }, ] }, [length]) - ctrl.current = controllers - - // The hooks reference api gets defined here ... - const api = useImperativeHandle(ref, () => ({ - start: () => - Promise.all(ctrl.current.map(c => new Promise(r => c.start(r)))), - stop: finished => ctrl.current.forEach(c => c.stop(finished)), - get controllers() { - return ctrl.current - }, - })) - - // This function updates the controllers - const updateCtrl = useMemo( - () => updateProps => - ctrl.current.map((c, i) => { - c.update(isFn ? callProp(updateProps, i, c) : updateProps[i]) - if (!ref) c.start() - }), - [length] - ) + // Attach the imperative API to its ref + useImperativeHandle(ref, () => api, [api]) // Update controller if props aren't functional useEffect(() => { + if (ctrl.current !== controllers) { + if (ctrl.current) ctrl.current.map(c => c.destroy()) + ctrl.current = controllers + } if (mounted.current) { - if (!isFn) updateCtrl(props) - } else if (!ref) ctrl.current.forEach(c => c.start()) + if (!isFn) setProps(props) + } else if (!ref) { + controllers.forEach(c => c.start()) + } }) // Update mounted flag and destroy controller on unmount - useEffect( - () => ( - (mounted.current = true), () => ctrl.current.forEach(c => c.destroy()) - ), - [] - ) + useEffect(() => { + mounted.current = true + return () => ctrl.current.forEach(c => c.destroy()) + }, []) // Return animated props, or, anim-props + the update-setter above - const propValues = ctrl.current.map(c => c.getValues()) + const values = controllers.map(c => c.getValues()) return isFn ? [ - propValues, - updateCtrl, - finished => ctrl.current.forEach(c => c.pause(finished)), + values, + setProps, + (key, finished) => ctrl.current.forEach(c => c.stop(key, finished)), ] - : propValues + : values } diff --git a/src/useTrail.js b/src/useTrail.js index 6fc8e1c6af..4b2bc72e83 100644 --- a/src/useTrail.js +++ b/src/useTrail.js @@ -36,7 +36,7 @@ export const useTrail = (length, props) => { attach: attachController && (() => attachController), } }), - [length, updateProps.reverse] + [length, updateProps.config] ) // Update controller if props aren't functional useEffect(() => void (mounted.current && !isFn && updateCtrl(props))) diff --git a/src/useTransition.js b/src/useTransition.js index af5f31bafd..ca458f011f 100644 --- a/src/useTransition.js +++ b/src/useTransition.js @@ -7,7 +7,13 @@ import { useCallback, } from 'react' import Ctrl from './animated/Controller' -import { is, toArray, callProp, useForceUpdate } from './shared/helpers' +import { + is, + toArray, + callProp, + interpolateTo, + useForceUpdate, +} from './shared/helpers' import { requestFrame } from './animated/Globals' /** API @@ -17,23 +23,31 @@ import { requestFrame } from './animated/Globals' let guid = 0 +const INITIAL = 'initial' const ENTER = 'enter' -const LEAVE = 'leave' const UPDATE = 'update' -const mapKeys = (items, keys) => +const LEAVE = 'leave' + +const makeKeys = (items, keys) => (typeof keys === 'function' ? items.map(keys) : toArray(keys)).map(String) -const get = props => { - let { items, keys = item => item, ...rest } = props - items = toArray(items !== void 0 ? items : null) - return { items, keys: mapKeys(items, keys), ...rest } + +const makeConfig = props => { + let { items, keys, ...rest } = props + items = toArray(is.und(items) ? null : items) + return { items, keys: makeKeys(items, keys), ...rest } } -export function useTransition(input, keyTransform, config) { - const props = { items: input, keys: keyTransform || (i => i), ...config } +export function useTransition(input, keyTransform, props) { + props = makeConfig({ + ...props, + items: input, + keys: keyTransform || (i => i), + }) const { lazy = false, unique = false, reset = false, + from, enter, leave, update, @@ -45,7 +59,7 @@ export function useTransition(input, keyTransform, config) { onStart, ref, ...extra - } = get(props) + } = props const forceUpdate = useForceUpdate() const mounted = useRef(false) @@ -80,17 +94,19 @@ export function useTransition(input, keyTransform, config) { if (state.current.changed) { // Update state state.current.transitions.forEach(transition => { - const { slot, from, to, config, trail, key, item } = transition + const { phase, key, item, props } = transition if (!state.current.instances.has(key)) state.current.instances.set(key, new Ctrl()) + // Avoid calling `onStart` more than once per transition. + let started = false + // update the map object const ctrl = state.current.instances.get(key) - const newProps = { + const itemProps = { + reset: reset && phase === ENTER, ...extra, - to, - from, - config, + ...props, ref, onRest: values => { if (state.current.mounted) { @@ -103,19 +119,22 @@ export function useTransition(input, keyTransform, config) { // A transition comes to rest once all its springs conclude const curInstances = Array.from(state.current.instances) const active = curInstances.some(([, c]) => !c.idle) - if (!active && (ref || lazy) && state.current.deleted.length > 0) + if (!active && (ref || lazy) && state.current.deleted.length > 0) { cleanUp(state) - if (onRest) onRest(item, slot, values) + } + if (onRest) { + onRest(item, phase, values) + } } }, - onStart: onStart && (() => onStart(item, slot)), - onFrame: onFrame && (values => onFrame(item, slot, values)), - delay: trail, - reset: reset && slot === ENTER, + onFrame: onFrame && (values => onFrame(item, phase, values)), + onStart: + onStart && + (() => started || (started = (onStart(item, phase), true))), } // Update controller - ctrl.update(newProps) + ctrl.update(itemProps) if (!state.current.paused) ctrl.start() }) } @@ -129,30 +148,30 @@ export function useTransition(input, keyTransform, config) { } }, []) - return state.current.transitions.map(({ item, slot, key }) => { + return state.current.transitions.map(({ item, phase, key }) => { return { item, key, - state: slot, + phase, props: state.current.instances.get(key).getValues(), } }) } -function cleanUp(state, filterKey) { - const deleted = state.current.deleted +function cleanUp({ current: state }, filterKey) { + const { deleted } = state for (let { key } of deleted) { const filter = t => t.key !== key if (is.und(filterKey) || filterKey === key) { - state.current.instances.delete(key) - state.current.transitions = state.current.transitions.filter(filter) - state.current.deleted = state.current.deleted.filter(filter) + state.instances.delete(key) + state.transitions = state.transitions.filter(filter) + state.deleted = state.deleted.filter(filter) } } - state.current.forceUpdate() + state.forceUpdate() } -function diffItems({ first, prevProps, ...state }, props) { +function diffItems({ first, current, deleted, prevProps, ...state }, props) { let { items, keys, @@ -165,84 +184,86 @@ function diffItems({ first, prevProps, ...state }, props) { unique, config, order = [ENTER, LEAVE, UPDATE], - } = get(props) - let { keys: _keys, items: _items } = get(prevProps) - let current = { ...state.current } - let deleted = [...state.deleted] + } = props + let { keys: _keys, items: _items } = makeConfig(prevProps) // Compare next keys with current keys - let currentKeys = Object.keys(current) - let currentSet = new Set(currentKeys) - let nextSet = new Set(keys) - let added = keys.filter(item => !currentSet.has(item)) - let removed = state.transitions - .filter(item => !item.destroyed && !nextSet.has(item.originalKey)) - .map(i => i.originalKey) - let updated = keys.filter(item => currentSet.has(item)) + const currentKeys = Object.keys(current) + const currentSet = new Set(currentKeys) + const nextSet = new Set(keys) + + const addedKeys = keys.filter(key => !currentSet.has(key)) + const updatedKeys = update ? keys.filter(key => currentSet.has(key)) : [] + const deletedKeys = state.transitions + .filter(t => !t.destroyed && !nextSet.has(t.originalKey)) + .map(t => t.originalKey) + let delay = -trail while (order.length) { - const changeType = order.shift() - switch (changeType) { - case ENTER: { - added.forEach((key, index) => { - // In unique mode, remove fading out transitions if their key comes in again - if (unique && deleted.find(d => d.originalKey === key)) - deleted = deleted.filter(t => t.originalKey !== key) - const keyIndex = keys.indexOf(key) - const item = items[keyIndex] - const slot = first && initial !== void 0 ? 'initial' : ENTER - current[key] = { - slot, - originalKey: key, - key: unique ? String(key) : guid++, - item, - trail: (delay = delay + trail), - config: callProp(config, item, slot), - from: callProp( - first ? (initial !== void 0 ? initial || {} : from) : from, - item - ), - to: callProp(enter, item), - } - }) - break + let phase = order.shift() + if (phase === ENTER) { + if (first && !is.und(initial)) { + phase = INITIAL } - case LEAVE: { - removed.forEach(key => { - const keyIndex = _keys.indexOf(key) - const item = _items[keyIndex] - const slot = LEAVE - deleted.unshift({ - ...current[key], - slot, - destroyed: true, - left: _keys[Math.max(0, keyIndex - 1)], - right: _keys[Math.min(_keys.length, keyIndex + 1)], - trail: (delay = delay + trail), - config: callProp(config, item, slot), - to: callProp(leave, item), - }) - delete current[key] + addedKeys.forEach(key => { + // In unique mode, remove fading out transitions if their key comes in again + if (unique && deleted.find(d => d.originalKey === key)) { + deleted = deleted.filter(t => t.originalKey !== key) + } + const i = keys.indexOf(key) + const item = items[i] + const enterProps = callProp(enter, item, i) + current[key] = { + phase, + originalKey: key, + key: unique ? String(key) : guid++, + item, + props: { + delay: (delay += trail), + config: callProp(config, item, phase), + from: callProp(first && !is.und(initial) ? initial : from, item), + to: enterProps, + ...(is.obj(enterProps) && interpolateTo(enterProps)), + }, + } + }) + } else if (phase === LEAVE) { + deletedKeys.forEach(key => { + const i = _keys.indexOf(key) + const item = _items[i] + const leaveProps = callProp(leave, item, i) + deleted.unshift({ + ...current[key], + phase, + destroyed: true, + left: _keys[Math.max(0, i - 1)], + right: _keys[Math.min(_keys.length, i + 1)], + props: { + delay: (delay += trail), + config: callProp(config, item, phase), + to: leaveProps, + ...(is.obj(leaveProps) && interpolateTo(leaveProps)), + }, }) - break - } - case UPDATE: { - updated.forEach(key => { - const keyIndex = keys.indexOf(key) - const item = items[keyIndex] - const slot = UPDATE - current[key] = { - ...current[key], - item, - slot, - trail: (delay = delay + trail), - config: callProp(config, item, slot), - to: callProp(update, item), - } - }) - break - } + delete current[key] + }) + } else if (phase === UPDATE) { + updatedKeys.forEach(key => { + const i = keys.indexOf(key) + const item = items[i] + const updateProps = callProp(update, item, i) + current[key] = { + ...current[key], + phase, + props: { + delay: (delay += trail), + config: callProp(config, item, phase), + to: updateProps, + ...(is.obj(updateProps) && interpolateTo(updateProps)), + }, + } + }) } } let out = keys.map(key => current[key]) @@ -260,8 +281,8 @@ function diffItems({ first, prevProps, ...state }, props) { return { ...state, - changed: added.length || removed.length || updated.length, - first: first && added.length === 0, + first: first && !addedKeys.length, + changed: !!(addedKeys.length || deletedKeys.length || updatedKeys.length), transitions: out, current, deleted, diff --git a/types/renderprops-universal.d.ts b/types/renderprops-universal.d.ts index c30ef0ad11..1e4a0710e5 100644 --- a/types/renderprops-universal.d.ts +++ b/types/renderprops-universal.d.ts @@ -23,6 +23,7 @@ export interface SpringConfig { clamp?: boolean precision?: number delay?: number + decay?: number duration?: number easing?: SpringEasingFunc } @@ -58,10 +59,6 @@ export interface SpringBaseProps { * reverse the animation */ reverse?: boolean - /** - * Callback when the animation starts to animate - */ - onStart?(): void } export interface SpringProps extends SpringBaseProps { @@ -74,15 +71,25 @@ export interface SpringProps extends SpringBaseProps { * Animates to... * @default {} */ - to?: DS + to?: + | Partial + | Array> + | (( + next: (props: DS & SpringProps) => void, + stop: (finished: boolean) => void + ) => Promise) /** - * Callback when the animation comes to a still-stand + * Called when an animation will begin */ - onRest?: (ds: DS) => void + onStart?: (animation: any) => void /** - * Frame by frame callback, first argument passed is the animated value + * Called when all animations have come to a stand-still */ - onFrame?: (ds: DS) => void + onRest?: (restValues: DS) => void + /** + * Called on every frame with the current values + */ + onFrame?: (currentValues: DS) => void /** * Takes a function that receives interpolated styles */ @@ -130,7 +137,7 @@ export function animated( export type TransitionKeyProps = string | number -export type State = 'enter' | 'update' | 'leave' +export type TransitionPhase = 'enter' | 'update' | 'leave' export interface TransitionProps< TItem, @@ -146,7 +153,9 @@ export interface TransitionProps< * Spring config, or for individual keys: fn((item,type) => config), where "type" can be either enter, leave or update * @default config.default */ - config?: SpringConfig | ((item: TItem, type: State) => SpringConfig) + config?: + | SpringConfig + | ((item: TItem, phase: TransitionPhase) => SpringConfig) /** * First-render initial values, if present overrides "from" on the first render pass. It can be "null" to skip first mounting transition. Otherwise it can take an object or a function (item => object) */ @@ -166,15 +175,22 @@ export interface TransitionProps< * @default {} */ leave?: TLeave | ((item: TItem) => TLeave) - /** - * Callback when the animation comes to a still-stand - */ - onRest?: (ds: DS) => void - /** * Values that apply to elements that are neither entering nor leaving (you can use this to update present elements), or: item => values */ update?: TUpdate | ((item: TItem) => TUpdate) + /** + * Called when an item's transition will begin + */ + onStart?: (item: TItem, phase: TransitionPhase) => void + /** + * Called when an item's transition has come to a stand-still + */ + onRest?: (item: TItem, phase: TransitionPhase, restValues: DS) => void + /** + * Called on every frame with the current values + */ + onFrame?: (item: TItem, phase: TransitionPhase, currentValues: DS) => void /** * The same keys you would normally hand over to React in a list. Keys can be specified as a key-accessor function, an array of keys, or a single value */ @@ -192,7 +208,7 @@ export interface TransitionProps< */ children?: ( item: TItem, - state: State, + phase: TransitionPhase, index: number ) => | boolean