diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000000..a300f2aba8 --- /dev/null +++ b/.npmignore @@ -0,0 +1 @@ +dist/__tests__ diff --git a/package.json b/package.json index 761f52ac7f..72ac0964aa 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "test": "jest", "test:dev": "jest --watch --no-coverage", "test:coverage:watch": "jest --watch", - "test:ts": "tsc --noEmit", + "test:ts": "tsc -p types/__tests__/tsconfig.json", "postinstall": "node -e \"console.log('\\u001b[35m\\u001b[1mEnjoy react-spring? You can now donate to our open collective:\\u001b[22m\\u001b[39m\\n > \\u001b[34mhttps://opencollective.com/react-spring/donate\\u001b[0m')\"" }, "husky": { @@ -97,6 +97,7 @@ "rollup-plugin-node-resolve": "4.0.0", "rollup-plugin-size-snapshot": "0.8.0", "rollup-plugin-uglify": "6.0.2", + "spec.ts": "^1.1.0", "typescript": "3.3.3" }, "peerDependencies": { 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..05fc730b4c 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 @@ -41,15 +42,15 @@ function addAnimatedStyles( export default class AnimatedValue extends Animated implements SpringValue { private animatedStyles = new Set() - public value: number | string - public startPosition: number | string - public lastPosition: number | string + public value: any + public startPosition: any + public lastPosition: any public lastVelocity?: number public startTime?: number public lastTime?: number public done = false - constructor(value: number | string) { + constructor(value: any) { super() this.value = value this.startPosition = value @@ -67,7 +68,7 @@ export default class AnimatedValue extends Animated implements SpringValue { this.animatedStyles.clear() } - public setValue = (value: number | string, flush = true) => { + public setValue = (value: any, flush = true) => { this.value = value if (flush) this.flush() } @@ -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..964005a513 100644 --- a/src/animated/AnimatedValueArray.ts +++ b/src/animated/AnimatedValueArray.ts @@ -6,12 +6,12 @@ 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) { + public setValue(value: any, flush = true) { if (Array.isArray(value)) { if (value.length === this.payload.length) { value.forEach((v, i) => this.payload[i].setValue(v, flush)) @@ -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..69c0f07e4a 100644 --- a/src/animated/Controller.ts +++ b/src/animated/Controller.ts @@ -7,373 +7,589 @@ 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 + + // 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) + props.timestamp = now() + + // 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) + } + } + + /** 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 = [] } - /** Pause sets onEnd listeners free, but also removes the controller from the frameloop */ - pause(finished?: boolean) { - this.stop(true) - if (finished) stop(this) + /** + * Set a prop for the next animations where the prop is undefined. The given + * value is overridden by the next update where the prop is defined. + * + * Ongoing animations are not changed. + */ + setProp

>( + key: P, + value: UpdateProps[P] + ) { + this.props[key] = value + this.timestamps[key] = now() return this } - 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) } - } else if (is.fun(props.to)) { - let index = 0 - let last: Promise + } + } + + // 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) + } + } + + 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(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 - - // Reverse values when requested + // Merge every fresh prop. Returns true if one or more props changed. + // These props are ignored: (config, immediate, reverse) + private _diff({ + timestamp, + config, + immediate, + reverse, + ...props + }: UpdateProps) { + let changed = false + + // 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 + } + } + } + if (reverse) { - ;[from, to] = [to, from] + const { to } = props + props.to = props.from + props.from = is.obj(to) ? (to as any) : void 0 + } + + for (const key in props) { + diffProp([key], props[key], this.props) } - // This will collect all props that were ever set, reset merged props when necessary - this.merged = { ...from, ...this.merged, ...to } + // Never cache "reset: true" + if (props.reset) { + this.props.reset = false + } + + return changed + } + + // Update the animation configs. The given props override any default props. + private _animate(props: UpdateProps) { + let { to = emptyObj, from = emptyObj } = this.props + + // 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 = this.props.attach && this.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 + } + + // Replace an animation when its goal value is changed (or it's been reset) + if (props.reset || !is.equ(goalValue, state.goalValue)) { + let { immediate } = is.und(props.immediate) ? this.props : props + immediate = callProp(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) { + if (props.reset) animated.setValue(fromValue, false) + 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) { + if (props.reset) animated.setValue(fromValue, false) + animated.reset(isActive) + } else { + animated = new AnimatedValue(fromValue) + } + } + if (immediate) { + animated.setValue(goalValue, 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() + // Only change the "config" of updated animations. + const config: SpringConfig = + callProp(props.config, key) || + callProp(this.props.config, key) || + emptyObj + + changed = true + animatedValues = toArray(animated.getPayload() as any) + 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 (this.props.onStart && started.length) { + started.forEach(key => this.props.onStart!(this.animations[key])) + } + + // 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) + } - getValues = () => this.interpolations + this.animations[key] = { key, animated, animatedValues } as any + animatedValues.forEach(v => (v.done = true)) + + // 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 { + return is.arr(value) + ? new AnimatedValueArray( + value.map(value => { + const animated = createAnimated(value) + const payload: any = animated.getPayload() + return animated instanceof AnimatedInterpolation + ? payload[0] + : payload + }) + ) + : isAnimatableString(value) + ? (new AnimatedValue(0).interpolate({ + output: [value, value] as any, + }) as any) + : new AnimatedValue(value) +} + +// Merge updates with the same delay. +// NOTE: Mutation of `props` may 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/elements.js b/src/elements.js new file mode 100644 index 0000000000..5b2a713b1b --- /dev/null +++ b/src/elements.js @@ -0,0 +1,22 @@ +import React from 'react' +import { useSpring } from './useSpring' +import { useTrail } from './useTrail' +import { useTransition } from './useTransition' + +export function Spring({ children, ...props }) { + const spring = useSpring(props) + return children(spring) +} + +export function Trail({ items, children, ...props }) { + const trails = useTrail(items.length, props) + return items.map((item, index) => children(item)(trails[index])) +} + +export function Transition({ items, keys = null, children, ...props }) { + const transitions = useTransition(items, keys, props) + return transitions.map(({ item, key, props, slot }, index) => { + const el = children(item, slot, index)(props) + return + }) +} diff --git a/src/shared/helpers.ts b/src/shared/helpers.ts index c47e3755f7..a9c0eba3e1 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,12 @@ 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 handleRef(ref: T, forward: Ref) { @@ -141,3 +140,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/targets/cookbook/index.js b/src/targets/cookbook/index.js index adb31edaae..a1e2b6ccc3 100644 --- a/src/targets/cookbook/index.js +++ b/src/targets/cookbook/index.js @@ -23,6 +23,7 @@ import { useSprings } from '../../useSprings' import { useTrail } from '../../useTrail' import { useTransition } from '../../useTransition' +export { Spring, Trail, Transition } from '../../elements' export { Globals, AnimatedInterpolation, diff --git a/src/targets/konva/index.ts b/src/targets/konva/index.ts index b64c6a42a6..ebe9fde8d7 100644 --- a/src/targets/konva/index.ts +++ b/src/targets/konva/index.ts @@ -70,6 +70,7 @@ type AnimatedWithKonvaElements = CreateAnimatedComponent & const apply = merge(animated as AnimatedWithKonvaElements, false) const extendedAnimated = apply(konvaElements) +export { Spring, Trail, Transition } from '../../elements' export { apply, config, diff --git a/src/targets/native/index.ts b/src/targets/native/index.ts index e6e1b58764..0d5b198d79 100644 --- a/src/targets/native/index.ts +++ b/src/targets/native/index.ts @@ -35,6 +35,7 @@ Globals.injectAnimatedApi((node, mounted, forceUpdate) => ({ const apply = merge(animated) +export { Spring, Trail, Transition } from '../../elements' export { apply, config, diff --git a/src/targets/three/index.ts b/src/targets/three/index.ts index 1872140ad9..9c2370f365 100644 --- a/src/targets/three/index.ts +++ b/src/targets/three/index.ts @@ -36,6 +36,7 @@ Globals.injectColorNames(colorNames) // This is how we teach react-spring to set props "natively", the api is (instance, props) => { ... } Globals.injectApplyAnimatedValues(applyProps, style => style) +export { Spring, Trail, Transition } from '../../elements' export { apply, update, diff --git a/src/targets/universal/index.ts b/src/targets/universal/index.ts index f84fd1af81..983cd5a50d 100644 --- a/src/targets/universal/index.ts +++ b/src/targets/universal/index.ts @@ -44,6 +44,7 @@ const Interpolation = { create: createInterpolator, } +export { Spring, Trail, Transition } from '../../elements' export { apply, config, diff --git a/src/targets/web/index.ts b/src/targets/web/index.ts index 216c48dd62..75e28dc454 100644 --- a/src/targets/web/index.ts +++ b/src/targets/web/index.ts @@ -160,6 +160,7 @@ type AnimatedWithDOMElements = CreateAnimatedComponent & const apply = merge(animated as AnimatedWithDOMElements, false) const extendedAnimated = apply(domElements) +export { Spring, Trail, Transition } from '../../elements' export { apply, config, 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..965c130672 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,69 @@ 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 + } + controllers.forEach((c, i) => { + c.setProp('config', props[i].config) + c.setProp('immediate', props[i].immediate) + }) 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, + (...args) => ctrl.current.forEach(c => c.stop(...args)), ] - : propValues + : values } diff --git a/src/useTrail.js b/src/useTrail.js index 6fc8e1c6af..d24eda49ce 100644 --- a/src/useTrail.js +++ b/src/useTrail.js @@ -33,10 +33,10 @@ export const useTrail = (length, props) => { return { ...props, config: callProp(props.config || updateProps.config, i), - attach: attachController && (() => attachController), + 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..2f2a78cd93 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,23 @@ 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 && + (animation => + started || (started = (onStart(item, phase, animation), true))), } // Update controller - ctrl.update(newProps) + ctrl.update(itemProps) if (!state.current.paused) ctrl.start() }) } @@ -129,30 +149,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 +185,89 @@ 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 && prevProps.items !== props.items + ? 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 +285,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/tsconfig.json b/tsconfig.json index 31a3342255..ddce81f363 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,5 @@ { + "include": ["src", "types"], "compilerOptions": { "target": "es2017", "moduleResolution": "node", @@ -15,6 +16,5 @@ "noUnusedLocals": true, "noUnusedParameters": true, "strict": true - }, - "include": ["src/**/*", "types/**/*.ts"] + } } diff --git a/types/__tests__/.prettierrc b/types/__tests__/.prettierrc new file mode 100644 index 0000000000..f2a7d6c730 --- /dev/null +++ b/types/__tests__/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "jsxBracketSameLine": true, + "tabWidth": 2, + "printWidth": 80 +} diff --git a/types/__tests__/Spring.tsx b/types/__tests__/Spring.tsx new file mode 100644 index 0000000000..5d7df1cebb --- /dev/null +++ b/types/__tests__/Spring.tsx @@ -0,0 +1,25 @@ +import { assert, test, _ } from 'spec.ts'; +import { Spring, animated, AnimatedValue } from '../web'; +import React from 'react'; + +const View = animated('div'); + +test('basic usage', () => { + { + assert(values, _ as { + [key: string]: unknown; + // FIXME: should include "opacity" and "color" + }); + }}> + {props => { + assert(props, _ as { + [key: string]: AnimatedValue; + // FIXME: should include "opacity" and "color" + }); + return ; + }} + ; +}); diff --git a/types/__tests__/Trail.tsx b/types/__tests__/Trail.tsx new file mode 100644 index 0000000000..f3a0b86c3c --- /dev/null +++ b/types/__tests__/Trail.tsx @@ -0,0 +1,21 @@ +import { assert, test, _ } from 'spec.ts'; +import { Trail, animated, AnimatedValue } from '../web'; +import React from 'react'; + +const View = animated('div'); + +const items = [1, 2] as [1, 2]; + +test('basic usage', () => { + + {item => props => { + assert(item, _ as 1 | 2); + assert(props, _ as { + [key: string]: AnimatedValue; + opacity: AnimatedValue; + color: AnimatedValue; + }); + return {item}; + }} + ; +}); diff --git a/types/__tests__/Transition.tsx b/types/__tests__/Transition.tsx new file mode 100644 index 0000000000..3cfc7a889e --- /dev/null +++ b/types/__tests__/Transition.tsx @@ -0,0 +1,26 @@ +import { assert, test, _ } from 'spec.ts'; +import { Transition, animated, AnimatedValue, TransitionPhase } from '../web'; +import React from 'react'; + +const View = animated('div'); + +const items = [1, 2] as [1, 2]; + +test('basic usage', () => { + + {(item, phase, i) => props => { + assert(props, _ as { + [key: string]: AnimatedValue; + opacity: AnimatedValue; + color: AnimatedValue; + }); + assert(item, _ as 1 | 2); + assert(phase, _ as TransitionPhase); + assert(i, _ as number); + return {item}; + }} + ; +}); diff --git a/types/__tests__/common.ts b/types/__tests__/common.ts new file mode 100644 index 0000000000..c72e34b8be --- /dev/null +++ b/types/__tests__/common.ts @@ -0,0 +1,168 @@ +import { + PickAnimated, + ForwardProps, + AnimatedProps, + AnimatedValue, + AnimationFrame, + UnknownProps, + Remap, +} from '../lib/common'; +import { assert, _, test } from 'spec.ts'; + +const $1: 1 = 1; + +const reservedProps = { + config: $1, + from: {}, + to: {}, + ref: $1, + reset: $1, + reverse: $1, + immediate: $1, + delay: $1, + lazy: $1, + onStart: $1, + onRest: $1, + onFrame: $1, +}; + +const forwardProps = { + foo: $1, + bar: $1, +}; + +type R = typeof reservedProps; +type F = typeof forwardProps; + +test('ForwardProps', () => { + // With reserved props, no forward props + type P1 = ForwardProps; + assert(_ as P1, _ as {}); + + // With reserved and forward props + type P2 = ForwardProps; + assert(_ as P2, _ as F); + + // With forward props, no reserved props + type P3 = ForwardProps; + assert(_ as P3, _ as F); + + // No reserved or forward props + type P4 = ForwardProps<{}>; + assert(_ as P4, _ as {}); +}); + +test('PickAnimated', () => { + // No props + type A1 = PickAnimated<{}>; + assert(_ as A1, _ as {}); + + // Forward props only + type A3 = PickAnimated; + assert(_ as A3, _ as F); + + // Forward props and "from" prop + type A4 = PickAnimated<{ + foo: 1; + width: 1; + from: { bar: 1; width: 2 }; + }>; + assert(_ as A4, _ as Remap); + + // "to" and "from" props + type A5 = PickAnimated<{ + to: { foo: 1; width: 1 }; + from: { bar: 1; width: 2 }; + }>; + assert(_ as A5, _ as Remap); + + // "useTransition" props + type A6 = PickAnimated<{ + from: { a: 1 }; + initial: { b: 1 }; + enter: { c: 1 }; + update: { d: 1 }; + leave: { e: 1 }; + }>; + assert( + _ as A6, + _ as { + a: 1; + b: 1; + c: 1; + d: 1; + e: 1; + } + ); + + // Same keys in each phase + type A7 = PickAnimated<{ + from: { a: 1 }; + enter: { a: 2 }; + leave: { a: 3 }; + update: { a: 4 }; + initial: { a: 5 }; + }>; + assert( + _ as A7, + _ as { + a: 1 | 2 | 3 | 4 | 5; + } + ); +}); + +test('AnimatedProps', () => { + // Primitive props + type P2 = AnimatedProps<{ foo?: number }>; + assert( + _ as P2, + _ as { + foo?: number | AnimatedValue; + } + ); + + // Object props + type P3 = AnimatedProps<{ foo?: { bar?: number } }>; + assert( + _ as P3, + _ as { + foo?: AnimatedProps<{ bar?: number }>; + } + ); + + // Array props + type P4 = AnimatedProps<{ foo: [number, number] }>; + assert( + _ as P4, + _ as { + foo: [number, number] | AnimatedValue<[number, number]>; + } + ); + + // Atomic object props + type P5 = AnimatedProps<{ + set: Set; + map: Map; + date: Date; + func: Function; + prom: Promise; + }>; + assert( + _ as P5, + _ as { + set: Set | AnimatedValue>; + map: Map | AnimatedValue>; + date: Date | AnimatedValue; + func: Function | AnimatedValue; + prom: Promise | AnimatedValue>; + } + ); +}); + +test('SpringFrame', () => { + type T1 = AnimationFrame<{}>; + assert(_ as T1, _ as UnknownProps); + + type T2 = AnimationFrame<{ to: { a: number }; from: { b: number } }>; + assert(_ as T2, _ as { [key: string]: unknown; a: number; b: number }); +}); diff --git a/types/__tests__/tsconfig.json b/types/__tests__/tsconfig.json new file mode 100644 index 0000000000..9fbfd8d8a6 --- /dev/null +++ b/types/__tests__/tsconfig.json @@ -0,0 +1,14 @@ +{ + "include": ["*.ts", "useChain.tsx"], + "compilerOptions": { + "target": "es2017", + "moduleResolution": "node", + "lib": ["dom", "es2017"], + "noEmit": true, + "jsx": "react", + "allowSyntheticDefaultImports": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "strict": true + } +} diff --git a/types/__tests__/useChain.tsx b/types/__tests__/useChain.tsx new file mode 100644 index 0000000000..fd840b4bb9 --- /dev/null +++ b/types/__tests__/useChain.tsx @@ -0,0 +1,16 @@ +import { test } from 'spec.ts'; +import { useChain, SpringHandle } from '../web'; +import { RefObject } from 'react'; + +const refs: RefObject[] = []; + +test('basic usage', () => { + // No timesteps + useChain(refs); + + // With timesteps + useChain(refs, [0, 1]); + + // Cut timesteps in half + useChain(refs, [0, 1], 1000 / 2); +}); diff --git a/types/__tests__/useSpring.tsx b/types/__tests__/useSpring.tsx new file mode 100644 index 0000000000..d5eaf6c17e --- /dev/null +++ b/types/__tests__/useSpring.tsx @@ -0,0 +1,250 @@ +import { assert, test, _ } from 'spec.ts'; +import React, { useRef } from 'react'; +import { UnknownProps } from '../lib/common'; +import { + animated, + useSpring, + AnimatedValue, + SpringHandle, + SpringStopFn, + SpringUpdateFn, +} from '../web'; + +test('infer return type via forward prop', () => { + const props = useSpring({ foo: 0 }); + assert(props, _ as { + [key: string]: AnimatedValue; + foo: AnimatedValue; + }); + + test('using with "animated()" component', () => { + const Test = animated((_: { style: { foo: number } }) => null); + return ; + }); +}); + +test('infer return type via "from" prop', () => { + const props = useSpring({ + from: { foo: 0 }, + }); + assert(props, _ as { + [key: string]: AnimatedValue; + foo: AnimatedValue; + }); +}); + +test('infer return type via "to" prop', () => { + const props = useSpring({ + to: { foo: 0 }, + }); + assert(props, _ as { + [key: string]: AnimatedValue; + foo: AnimatedValue; + }); +}); + +test('infer return type via "from" and "to" props', () => { + const props = useSpring({ + from: { foo: 0 }, + to: { bar: '1' }, + }); + assert(props, _ as { + [key: string]: AnimatedValue; + foo: AnimatedValue; + bar: AnimatedValue; + }); +}); + +test('infer return type via "from" and forward props', () => { + const props = useSpring({ + from: { foo: 0 }, + bar: '1', + }); + assert(props, _ as { + [key: string]: AnimatedValue; + foo: AnimatedValue; + bar: AnimatedValue; + }); +}); + +test('infer animated array', () => { + const props = useSpring({ + to: { foo: [0, 0] }, + }); + assert(props, _ as { + [key: string]: AnimatedValue; + foo: AnimatedValue; + }); + + test('interpolated array', () => { + props.foo.interpolate((a, b) => { + assert(a, _ as number); + assert(b, _ as number); + }); + }); +}); + +test('imperative mode', () => { + const [props, update, stop] = useSpring(() => ({ + foo: 0, + onRest(values) { + assert(values, _ as UnknownProps); // FIXME: should be "UnknownProps & { foo: number }" + }, + })); + assert(props, _ as { + [key: string]: AnimatedValue; + foo: AnimatedValue; + }); + assert(update, _ as SpringUpdateFn<{ foo: number }>); + assert(stop, _ as SpringStopFn); + + test('update()', () => { + // Update an existing animated key + update({ foo: 100 }); + + // Add an animated key + update({ bar: 100 }); + + // With event listener + update({ + onRest(values) { + assert(values, _ as { + [key: string]: unknown; + foo: number; + }); + }, + }); + }); + + test('stop()', () => { + stop(); + stop(true); + stop(true, 'foo'); + stop('foo'); + stop('foo', 'bar'); + }); + + test('with delay and reset', () => { + const [props] = useSpring(() => ({ + foo: 0, + delay: 1000, + reset: true, + })); + assert(props, _ as { + [key: string]: AnimatedValue; + foo: AnimatedValue; + }); + }); + + test('with callbacks', () => { + const [props] = useSpring(() => ({ + foo: 0, + onStart(anim) { + assert(anim, _ as any); + }, + onFrame(values) { + assert(values, _ as UnknownProps); // FIXME: should be "UnknownProps & { foo: number }" + }, + onRest(values) { + assert(values, _ as UnknownProps); // FIXME: should be "UnknownProps & { foo: number }" + }, + })); + assert(props, _ as { + [key: string]: AnimatedValue; + foo: AnimatedValue; + }); + }); +}); + +test('spring refs', () => { + const ref = useRef(null); + useSpring({ foo: 1, ref }); + ref.current!.start(); + ref.current!.stop(true, 'foo', 'bar'); + ref.current!.stop(); +}); + +test('basic config', () => { + const props = useSpring({ + from: { width: 0 }, + reset: true, + delay: 1000, + onStart(animation) { + assert(animation, _ as any); + }, + onFrame(values) { + assert(values, _ as UnknownProps); // FIXME: should be "UnknownProps & { width: number }" + }, + onRest(values) { + assert(values, _ as UnknownProps); // FIXME: should be "UnknownProps & { width: number }" + }, + }); + assert(props, _ as { + [key: string]: AnimatedValue; + width: AnimatedValue; + }); +}); + +test('function as "to" prop', () => { + const props = useSpring({ + to: async next => { + assert(next, _ as SpringUpdateFn); + + // Unknown keys can be animated. + await next({ width: '100%' }); + + await next({ + foo: 100, + delay: 1000, + config: { duration: 1000 }, + onRest(values) { + assert(values, _ as UnknownProps); // FIXME: should be "UnknownProps & { foo: number }" + }, + }); + }, + }); + assert(props, _ as { + [key: string]: AnimatedValue; + }); + + test('with "from" prop', () => { + const props = useSpring({ + from: { foo: 1 }, + to: async next => { + assert(next, _ as SpringUpdateFn); // FIXME: should be "SpringUpdateFn<{ foo: number }>" + await next({ + onRest(values) { + assert(values, _ as UnknownProps); // FIXME: should be "UnknownProps & { foo: number }" + }, + }); + }, + }); + assert(props, _ as { + [key: string]: AnimatedValue; + foo: AnimatedValue; + }); + }); +}); + +test('array as "to" prop', () => { + // ⚠️ Animated keys are not inferred when "to" is an array (unless "from" exists) + const props = useSpring({ + to: [{ opacity: 1 }, { opacity: 0 }], + foo: 0, // ️️⚠️ This key is ignored because "to" exists + }); + assert(props, _ as { + [key: string]: AnimatedValue; + opacity: AnimatedValue; + }); + + test('with "from" prop', () => { + const props = useSpring({ + to: [{ opacity: 1 }, { opacity: 0 }], + from: { opacity: 0 }, + }); + assert(props, _ as { + [key: string]: AnimatedValue; + opacity: AnimatedValue; + }); + }); +}); diff --git a/types/__tests__/useSprings.tsx b/types/__tests__/useSprings.tsx new file mode 100644 index 0000000000..452942f255 --- /dev/null +++ b/types/__tests__/useSprings.tsx @@ -0,0 +1,35 @@ +import { assert, test, _ } from 'spec.ts'; +import { useSprings } from '../web'; +import { AnimatedValue } from '../lib/common'; +import { SpringUpdateFn, SpringStopFn } from '../lib/useSpring'; + +const items: string[] = []; + +test('pass an array', () => { + const springs = useSprings( + items.length, + items.map(item => { + assert(item, _ as string); + return { opacity: 1 / Number(item) }; + }) + ); + assert(springs, _ as Array<{ + [key: string]: AnimatedValue; + opacity: AnimatedValue; + }>); +}); + +test('pass a function', () => { + const [springs, set, stop] = useSprings(2, i => { + assert(i, _ as number); + return { opacity: i }; + }); + assert(springs, _ as Array<{ + [key: string]: AnimatedValue; + opacity: AnimatedValue; + }>); + assert(set, _ as SpringUpdateFn<{ + opacity: number; + }>); + assert(stop, _ as SpringStopFn); +}); diff --git a/types/__tests__/useTrail.tsx b/types/__tests__/useTrail.tsx new file mode 100644 index 0000000000..34eaeee5e3 --- /dev/null +++ b/types/__tests__/useTrail.tsx @@ -0,0 +1,24 @@ +import { assert, _ } from 'spec.ts'; +import { useTrail } from '../web'; +import { AnimatedValue } from '../lib/common'; +import { SpringUpdateFn, SpringStopFn } from '../lib/useSpring'; + +test('basic usage', () => { + const springs = useTrail(3, { opacity: 1 }); + assert(springs, _ as Array<{ + [key: string]: AnimatedValue; + opacity: AnimatedValue; + }>); +}); + +test('function argument', () => { + const [springs, set, stop] = useTrail(3, () => ({ opacity: 1 })); + assert(springs, _ as Array<{ + [key: string]: AnimatedValue; + opacity: AnimatedValue; + }>); + assert(set, _ as SpringUpdateFn<{ + opacity: number; + }>); + assert(stop, _ as SpringStopFn); +}); diff --git a/types/__tests__/useTransition.tsx b/types/__tests__/useTransition.tsx new file mode 100644 index 0000000000..9a2ec7795d --- /dev/null +++ b/types/__tests__/useTransition.tsx @@ -0,0 +1,71 @@ +import { assert, test, _ } from 'spec.ts'; +import { useTransition, ItemTransition } from '../lib/useTransition'; +import React from 'react'; +import { animated, AnimatedValue } from '../lib/common'; +import { SpringUpdateFn } from '../lib/useSpring'; + +const View = animated('div'); + +const items = [1, 2] as [1, 2]; + +test('infer animated from these props', () => { + const [transition] = useTransition(items, null, { + from: { a: 1 }, + enter: { b: 1 }, + leave: { c: 1 }, + update: { d: 1 }, + initial: { e: 1 }, + }); + assert(transition.props, _ as { + [key: string]: AnimatedValue; + a: AnimatedValue; + b: AnimatedValue; + c: AnimatedValue; + d: AnimatedValue; + e: AnimatedValue; + }); +}); + +test('basic usage', () => { + const transitions = useTransition(items, null, { + from: { opacity: 0 }, + enter: [{ opacity: 1 }, { color: 'red' }], + leave: { opacity: 0 }, + }); + + // You typically map transition objects into JSX elements. + return transitions.map(transition => { + type T = ItemTransition<1 | 2, { opacity: number; color: string }>; + assert(transition, _ as T); + return {transition.item}; + }); +}); + +test('with function props', () => { + const transitions = useTransition(items, null, { + from: item => { + assert(item, _ as 1 | 2); + return { width: 0, height: 0 }; + }, + enter: item => { + assert(item, _ as 1 | 2); + return { width: item * 100, height: '100%' }; + }, + leave: { width: '0%', opacity: 0 }, + }); + assert(transitions[0].props, _ as { + [key: string]: AnimatedValue; + width: AnimatedValue; + height: AnimatedValue; + opacity: AnimatedValue; + }); + + test('return an async function', () => { + useTransition(items, null, { + update: item => async next => { + assert(item, _ as 1 | 2); + assert(next, _ as SpringUpdateFn); // FIXME: should be "SpringUpdateFn<{ opacity: number, ... }>" + }, + }); + }); +}); diff --git a/types/lib/common.d.ts b/types/lib/common.d.ts new file mode 100644 index 0000000000..a2d4485f3c --- /dev/null +++ b/types/lib/common.d.ts @@ -0,0 +1,278 @@ +import { + ComponentPropsWithRef, + ForwardRefExoticComponent, + ReactType, + CSSProperties, +} from 'react' + +/** Spring presets */ +export const config: { + /** default: { tension: 170, friction: 26 } */ + default: SpringConfig + /** gentle: { tension: 120, friction: 14 } */ + gentle: SpringConfig + /** wobbly: { tension: 180, friction: 12 } */ + wobbly: SpringConfig + /** stiff: { tension: 210, friction: 20 } */ + stiff: SpringConfig + /** slow: { tension: 280, friction: 60 } */ + slow: SpringConfig + /** molasses: { tension: 280, friction: 120 } */ + molasses: SpringConfig +} + +/** Spring animation config */ +export interface SpringConfig { + mass?: number + tension?: number + friction?: number + velocity?: number + clamp?: boolean + precision?: number + delay?: number + decay?: number + duration?: number + easing?: SpringEasingFunc +} + +/** Time-based interpolation */ +export type SpringEasingFunc = (t: number) => number + +/** + * Animation-related props + */ +export interface AnimationProps extends AnimationEvents { + /** + * Configure the spring behavior for each key. + */ + config?: SpringConfig | ((key: string) => SpringConfig) + /** + * Milliseconds to wait before applying the other props. + */ + delay?: number + /** + * When true, props jump to their goal values instead of animating. + */ + immediate?: boolean | ((key: string) => boolean) + /** + * Start the next animations at their values in the `from` prop. + */ + reset?: boolean + /** + * Swap the `to` and `from` props. + */ + reverse?: boolean +} + +export interface AnimationEvents { + /** + * Called when an animation is about to start + */ + onStart?: (animation: any) => void + /** + * Called when all animations come to a stand-still + */ + onRest?: (restValues: AnimationFrame) => void + /** + * Called on every frame when animations are active + */ + onFrame?: (currentValues: AnimationFrame) => void +} + +/** The current values in a specific frame */ +export type AnimationFrame = Remap< + UnknownProps & ({} extends PickAnimated ? unknown : PickAnimated) +> + +/** Create a HOC that accepts `AnimatedValue` props */ +export function animated( + wrappedComponent: T +): AnimatedComponent + +/** The type of an `animated()` component */ +export type AnimatedComponent = ForwardRefExoticComponent< + AnimatedProps> +> + +/** The props of an `animated()` component */ +export type AnimatedProps = Solve< + { [P in keyof Props]: P extends 'ref' ? Props[P] : AnimatedProp } +> + +/** The value of an `animated()` component's prop */ +export type AnimatedProp = T extends void + ? never + : T extends AtomicObject + ? T | AnimatedValue + : T extends object + ? AnimatedProps + : T | AnimatedValue + +/** + * An animated value that can be passed into an `animated()` component. + */ +export interface AnimatedValue { + interpolate: InterpolationChain + getValue: () => T +} + +/** + * The map of `Animated` objects passed into `animated()` components. + * + * Parameter `T` represents the options object passed into `useSpring`. + */ +export type SpringValues = Remap< + { [key: string]: AnimatedValue } & ({} extends PickAnimated + ? unknown + : { [P in keyof PickAnimated]: AnimatedValue[P]> }) +> + +/** For solving generic types */ +export type Solve = T + +/** For resolving object intersections */ +export type Remap = Solve<{ [P in keyof T]: T[P] }> + +/** Intersected with other object types to allow for unknown properties */ +export type UnknownProps = { [key: string]: unknown } + +/** Infer an object from `ReturnType` or `T` itself */ +type InferObject = T extends + | ReadonlyArray + | ((...args: any[]) => infer U) + ? (U extends object ? U : {}) + : (T extends object ? T : {}) + +/** Extract a union of animated props from a `useTransition` config */ +type TransitionValues = TransitionPhase extends infer Phase + ? Phase extends keyof T + ? NoVoid> + : {} + : never + +/** The phases of a `useTransition` item */ +export type TransitionPhase = 'initial' | 'enter' | 'update' | 'leave' + +/** String union of the transition phases defined in `T` */ +export type TransitionPhases = { + [P in TransitionPhase]: P extends keyof T ? P : never +}[TransitionPhase] + +type NoVoid = { + [P in keyof T]-?: T[P] extends void ? never : T[P] +} + +/** Pick the properties that will be animated */ +export type PickAnimated = ObjectFromUnion< + | (T extends { from: infer FromProp } ? NoVoid> : {}) + | (TransitionPhases extends never ? ToValues : TransitionValues) +> + +/** Extract `to` values from a `useSpring` config */ +type ToValues = T extends { to: infer ToProp } + ? ToProp extends Function + ? {} + : ToProp extends ReadonlyArray + ? (U extends object ? U : {}) + : (ToProp extends object ? ToProp : {}) + : ForwardProps + +/** Intersect a union of objects but merge property types with _unions_ */ +export type ObjectFromUnion = Remap< + { + [P in keyof Intersect]: T extends infer U + ? P extends keyof U + ? U[P] + : never + : never + } +> + +/** Convert a union to an intersection */ +type Intersect = (U extends any ? (k: U) => void : never) extends (( + k: infer I +) => void) + ? I + : never + +/** Convert a map of `AnimatedValue` objects to their raw values */ +export type RawValues = { + [P in keyof T]: T[P] extends AnimatedValue ? U : T[P] +} + +/** Override the property types of `A` with `B` and merge any new properties */ +export type Merge = Solve< + { [K in keyof A]: K extends keyof B ? B[K] : A[K] } & B +> + +/** + * Extract the custom props that are treated like `to` values + */ +export type ForwardProps = keyof T extends ReservedProps + ? {} + : Pick> + +/** + * Property names that are reserved for animation config + */ +export type ReservedProps = + | 'config' + | 'from' + | 'to' + | 'ref' + | 'reset' + | 'reverse' + | 'immediate' + | 'delay' + | 'lazy' + | 'items' + | 'trail' + | 'unique' + | 'initial' + | 'enter' + | 'leave' + | 'update' + | 'onStart' + | 'onRest' + | 'onFrame' + | 'onDestroyed' + | 'timestamp' + +/** + * The input `range` and `output` range of an interpolation + */ +export type InterpolationConfig = { + range: T[] + output: U[] +} + +/** + * An "interpolator" transforms an animated value. Animated arrays are spread + * into the interpolator. + */ +export type Interpolator = T extends any[] + ? ((...params: T) => U) + : ((params: T) => U) + +/** + * A chain of interpolated values + */ +export interface InterpolationChain { + (config: InterpolationConfig): AnimatedValue + (interpolator: Interpolator): AnimatedValue +} + +/** Object types that should never be mapped */ +type AtomicObject = + | ReadonlyArray + | Function + | Map + | WeakMap + | Set + | WeakSet + | Promise + | Date + | RegExp + | Boolean + | Number + | String diff --git a/types/lib/render-props.d.ts b/types/lib/render-props.d.ts new file mode 100644 index 0000000000..980c2e74e8 --- /dev/null +++ b/types/lib/render-props.d.ts @@ -0,0 +1,35 @@ +import { ReactNode } from 'react' +import { UseSpringProps, UseSpringBaseProps } from './useSpring' +import { TransitionPhase, SpringValues, Merge, PickAnimated } from './common' +import { UseTransitionProps, ItemsProp, ItemKeys } from './useTransition' + +export const Spring: ( + props: UseSpringProps & { + children: (props: SpringValues) => ReactNode + } +) => JSX.Element + +export const Trail: ( + props: Merge< + UseSpringProps, + { + items: ReadonlyArray + children: (item: Item) => (props: SpringValues) => ReactNode + } + > +) => JSX.Element + +export const Transition: ( + props: Merge< + Props, + UseTransitionProps & { + items: ItemsProp + children: ( + item: Item, + phase: TransitionPhase, + index: number + ) => (props: SpringValues) => ReactNode + keys?: ItemKeys + } + > +) => JSX.Element diff --git a/types/lib/useChain.d.ts b/types/lib/useChain.d.ts new file mode 100644 index 0000000000..1a35b75068 --- /dev/null +++ b/types/lib/useChain.d.ts @@ -0,0 +1,15 @@ +import { RefObject } from 'react' +import { SpringHandle } from './useSpring' + +// export function useChain(refs: ReadonlyArray>): void +// export function useChain( +// refs: ReadonlyArray>, +// timeSteps: number[], +// timeFrame?: number +// ): void + +export function useChain( + refs: ReadonlyArray>, + timeSteps?: number[], + timeFrame?: number +): void diff --git a/types/lib/useSpring.d.ts b/types/lib/useSpring.d.ts new file mode 100644 index 0000000000..bf3565f7ca --- /dev/null +++ b/types/lib/useSpring.d.ts @@ -0,0 +1,90 @@ +import { + PickAnimated, + UnknownProps, + AnimationProps, + SpringValues, + AnimationEvents, + Merge, +} from './common' +import { RefObject } from 'react' + +export function useSpring( + props: () => UseSpringProps +): [SpringValues, SpringUpdateFn>, SpringStopFn] + +export function useSpring( + props: UseSpringProps +): SpringValues + +/** The props that `useSpring` recognizes */ +export type UseSpringProps = Props & + UseSpringBaseProps & { + /** + * The start values of the first animations. + * + * The `reset` prop also uses these values. + */ + from?: Partial> + /** + * The end values of the current animations. + * + * As an array, it creates a chain of animations. + * + * As an async function, it can create animations on-the-fly. + */ + to?: ToProp> + } + +type UnknownPartial = UnknownProps & Partial + +type ToProp = + | UnknownPartial + | ReadonlyArray & UseSpringProps> + | SpringAsyncFn + +/** Static `useSpring` props (use with `extends` or `&`) */ +export interface UseSpringBaseProps extends AnimationProps { + /** + * Used to access the imperative API. + * + * Animations never auto-start when `ref` is defined. + */ + ref?: RefObject +} + +export interface SpringStopFn { + /** Stop all animations and delays */ + (finished?: boolean): void + /** Stop the animations and delays of the given keys */ + (...keys: string[]): void + /** Stop the animations and delays of the given keys */ + (finished: boolean, ...keys: string[]): void +} + +/** An imperative update to the props of a spring */ +export type SpringUpdate = UnknownProps & + Merge>, AnimationEvents> + +/** Imperative API for updating the props of a spring */ +export interface SpringUpdateFn { + /** Update the props of a spring */ + (props: SpringUpdate): void +} + +/** An async function that can update or cancel the animations of a spring */ +export type SpringAsyncFn = ( + next: SpringUpdateFn, + stop: SpringStopFn +) => Promise + +/** + * Imperative animation controller + * + * Created by `useSpring` or `useSprings` for the `ref` prop + */ +export interface SpringHandle { + /** Start any pending animations */ + start: () => void + /** Stop one or more animations */ + stop: SpringStopFn +} diff --git a/types/lib/useSprings.d.ts b/types/lib/useSprings.d.ts new file mode 100644 index 0000000000..a4a875089d --- /dev/null +++ b/types/lib/useSprings.d.ts @@ -0,0 +1,12 @@ +import { UseSpringProps, SpringUpdateFn, SpringStopFn } from './useSpring' +import { SpringValues, PickAnimated } from './common' + +export function useSprings( + count: number, + props: (i: number) => UseSpringProps +): [SpringValues[], SpringUpdateFn>, SpringStopFn] + +export function useSprings( + count: number, + props: ReadonlyArray> +): SpringValues[] diff --git a/types/lib/useTrail.d.ts b/types/lib/useTrail.d.ts new file mode 100644 index 0000000000..e8abef2837 --- /dev/null +++ b/types/lib/useTrail.d.ts @@ -0,0 +1,12 @@ +import { UseSpringProps, SpringUpdateFn, SpringStopFn } from './useSpring' +import { SpringValues, PickAnimated } from './common' + +export function useTrail( + count: number, + props: () => UseSpringProps +): [SpringValues[], SpringUpdateFn>, SpringStopFn] + +export function useTrail( + count: number, + props: UseSpringProps +): SpringValues[] diff --git a/types/lib/useTransition.d.ts b/types/lib/useTransition.d.ts new file mode 100644 index 0000000000..dcfd993351 --- /dev/null +++ b/types/lib/useTransition.d.ts @@ -0,0 +1,122 @@ +import { + SpringValues, + UnknownProps, + SpringConfig, + Solve, + TransitionPhase, +} from './common' +import { SpringAsyncFn, SpringUpdate } from './useSpring' + +export type ItemsProp = ReadonlyArray | T | null | undefined +export type ItemKeys = + | ((item: T) => string | number) + | ReadonlyArray + | string + | number + | null + +/** + * Animate a set of values whenever one changes. + * + * The returned array can be safely mutated. + */ +export function useTransition< + Item, + Props extends UnknownProps & UseTransitionProps +>( + items: ItemsProp, + keys: ItemKeys, + props: Props +): ItemTransition[] + +/** The transition state of a single item */ +export type ItemTransition = Solve<{ + key: string | number + item: Item + phase: TransitionPhase + props: SpringValues +}> + +/** For props that provide animated keys */ +type TransitionProp = + | SpringUpdate + | ReadonlyArray + | ((item: Item) => SpringUpdate | SpringAsyncFn) + +export type UseTransitionProps = { + /** + * Base values (from -> enter), or: item => values + * @default {} + */ + from?: TransitionProp + /** + * Values that apply to new elements, or: item => values + * @default {} + */ + enter?: TransitionProp + /** + * Values that apply to leaving elements, or: item => values + * @default {} + */ + leave?: TransitionProp + /** + * Values that apply to elements that are neither entering nor leaving (you can use this to update present elements), or: item => values + */ + update?: TransitionProp + /** + * 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) + */ + initial?: TransitionProp + /** + * Configure the spring behavior for each item. + */ + config?: SpringConfig | ((item: Item, phase: TransitionPhase) => SpringConfig) + /** + * The same keys you would normally hand over to React in a list. + */ + keys?: + | ((item: Item) => string | number) + | ReadonlyArray + | string + | number + /** + * When this and `unique` are both true, items in the "enter" phase start from + * their values in the "from" prop instead of their current positions. + */ + reset?: boolean + /** + * Milliseconds of delay before animating the next item. + * + * This applies to all transition phases. + */ + trail?: number + /** + * When true, no two items can have the same key. Reuse any items that + * re-enter before they finish leaving. + */ + unique?: boolean + /** + * Called when an animation is about to start + */ + onStart?: (item: Item, phase: TransitionPhase, animation: any) => void + /** + * Called when all animations come to a stand-still + */ + onRest?: ( + item: Item, + phase: TransitionPhase, + restValues: UnknownProps + ) => void + /** + * Called on every frame when animations are active + */ + onFrame?: ( + item: Item, + phase: TransitionPhase, + currentValues: UnknownProps + ) => void + /** + * Called after an object has finished its "leave" transition + */ + onDestroyed?: (item: Item) => void +} 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 diff --git a/types/web.d.ts b/types/web.d.ts index 7e5fe7a8d1..be1a421c20 100644 --- a/types/web.d.ts +++ b/types/web.d.ts @@ -1,230 +1,21 @@ -import { CSSProperties, RefObject } from 'react' import { - SpringConfig, - SpringBaseProps, - TransitionKeyProps, - State, -} from './renderprops-universal' -export { SpringConfig, SpringBaseProps, TransitionKeyProps, State } - -export { config, interpolate } from './renderprops-universal' -// hooks are currently web-only -export { animated } from './renderprops' - -/** List from `function getForwardProps` in `src/shared/helpers` */ -type ExcludedProps = - | 'to' - | 'from' - | 'config' - | 'onStart' - | 'onRest' - | 'onFrame' - | 'children' - | 'reset' - | 'reverse' - | 'force' - | 'immediate' - | 'delay' - | 'attach' - | 'destroyed' - | 'interpolateTo' - | 'ref' - | 'lazy' - -// The config options for an interoplation. It maps out from in "in" type -// to an "out" type. -export type InterpolationConfig = { - range: T[] - output: U[] -} - -// The InterpolationChain is either a function that takes a config object -// and returns the next chainable type or it is a function that takes in params -// and maps out to another InterpolationChain. -export interface InterpolationChain { - (config: InterpolationConfig): OpaqueInterpolation - (interpolator: (params: T) => U): OpaqueInterpolation -} - -// The opaque interpolation masks as its original type but provides to helpers -// for chaining the interpolate method and getting its raw value. -export type OpaqueInterpolation = { - interpolate: InterpolationChain - getValue: () => T -} & T - -// Map all keys to our OpaqueInterpolation type which can either be interpreted -// as its initial value by "animated.{tag}" or chained with interpolations. -export type AnimatedValue = { - [P in keyof T]: OpaqueInterpolation -} - -// Make ForwardedProps chainable with interpolate / make it an animated value. -export type ForwardedProps = Pick> - -// NOTE: because of the Partial, this makes a weak type, which can have excess props -type InferFrom = T extends { to: infer TTo } - ? Partial - : Partial> - -// This is similar to "Omit & B", -// but with a delayed evaluation that still allows A to be inferrable -type Merge = { [K in keyof A]: K extends keyof B ? B[K] : A[K] } & B - -export type SetUpdateFn = (ds: Partial>) => void -export interface SetUpdateCallbackFn { - (ds: Partial>): void; - (i: number): Partial>; -} - -// The hooks do emulate React's 'ref' by accepting { ref?: React.RefObject } and -// updating it. However, there are no types for Controller, and I assume it is intentionally so. -// This is a partial interface for Controller that has only the properties needed for useChain to work. -export interface ReactSpringHook { - start(): void - stop(): void -} + animated as createAnimatedComponent, + AnimatedComponent, +} from './lib/common' -export function useChain(refs: ReadonlyArray>): void -// this looks like it can just be a single overload, but we don't want to allow -// timeFrame to be specifiable when timeSteps is explicitly "undefined" -export function useChain( - refs: ReadonlyArray>, - timeSteps: number[], - timeFrame?: number -): void +export const animated: typeof createAnimatedComponent & + { [Tag in keyof JSX.IntrinsicElements]: AnimatedComponent } -export interface HooksBaseProps - extends Pick> { - /** - * Will skip rendering the component if true and write to the dom directly. - * @default true - * @deprecated - */ - native?: never - // there is an undocumented onKeyframesHalt which passes the controller instance, - // so it also cannot be typed unless Controller types are written - ref?: React.RefObject -} - -export interface UseSpringBaseProps extends HooksBaseProps { - config?: SpringBaseProps['config'] -} - -export type UseSpringProps = Merge< - DS & UseSpringBaseProps, - { - from?: InferFrom - /** - * Callback when the animation comes to a still-stand - */ - onRest?(ds: InferFrom): void - } -> - -type OverwriteKeys = { [K in keyof A]: K extends keyof B ? B[K] : A[K] }; - -// there's a third value in the tuple but it's not public API (?) -export function useSpring( - values: UseSpringProps> -): AnimatedValue>> -export function useSpring( - getProps: () => UseSpringProps> -): [AnimatedValue>>, SetUpdateFn>] - -// there's a third value in the tuple but it's not public API (?) -export function useSprings( - count: number, - items: ReadonlyArray, -): ForwardedProps[] // safe to modify (result of .map) -export function useSprings( - count: number, - getProps: (i: number) => UseSpringProps -): [AnimatedValue>[], SetUpdateCallbackFn] - -// there's a third value in the tuple but it's not public API (?) -export function useTrail( - count: number, - getProps: () => UseSpringProps -): [ForwardedProps[], SetUpdateFn] -export function useTrail( - count: number, - values: UseSpringProps -): ForwardedProps[] // safe to modify (result of .map) -export function useTrail( - count: number, - getProps: () => UseSpringProps -): [AnimatedValue>[], SetUpdateFn] -export function useTrail( - count: number, - values: UseSpringProps -): AnimatedValue>[] // safe to modify (result of .map) - -export interface UseTransitionProps - extends HooksBaseProps { - /** - * Spring config, or for individual items: fn(item => config) - * @default config.default - */ - config?: SpringConfig | ((item: TItem) => SpringConfig) - - /** - * When true enforces that an item can only occur once instead of allowing two or more items with the same key to co-exist in a stack - * @default false - */ - unique?: boolean - /** - * Trailing delay in ms - */ - trail?: number - - from?: InferFrom | ((item: TItem) => InferFrom) - /** - * Values that apply to new elements, or: item => values - * @default {} - */ - enter?: InferFrom | InferFrom[] | ((item: TItem) => InferFrom) - /** - * Values that apply to leaving elements, or: item => values - * @default {} - */ - leave?: InferFrom | InferFrom[] | ((item: TItem) => InferFrom) - /** - * Values that apply to elements that are neither entering nor leaving (you can use this to update present elements), or: item => values - */ - update?: InferFrom | InferFrom[] | ((item: TItem) => InferFrom) - /** - * Initial (first time) base values, optional (can be null) - */ - initial?: InferFrom | ((item: TItem) => InferFrom) | null - /** - * Called when objects have disappeared for good - */ - onDestroyed?: (isDestroyed: boolean) => void -} - -export interface UseTransitionResult { - item: TItem - key: string - state: State - props: AnimatedValue> -} - -export function useTransition( - items: ReadonlyArray | TItem | null | undefined, - keys: - | ((item: TItem) => TransitionKeyProps) - | ReadonlyArray - | TransitionKeyProps - | null, - values: Merge> -): UseTransitionResult>[] // result array is safe to modify -export function useTransition( - items: ReadonlyArray | TItem | null | undefined, - keys: - | ((item: TItem) => TransitionKeyProps) - | ReadonlyArray - | TransitionKeyProps - | null, - values: Merge> -): UseTransitionResult>>[] // result array is safe to modify +export { + config, + AnimatedValue, + SpringConfig, + TransitionPhase, +} from './lib/common' + +export * from './lib/useSpring' +export * from './lib/useSprings' +export * from './lib/useTransition' +export * from './lib/useTrail' +export * from './lib/useChain' +export * from './lib/render-props'