diff --git a/src/animated/Controller.ts b/src/animated/Controller.ts index 0aba4d2e64..fd7f125b95 100644 --- a/src/animated/Controller.ts +++ b/src/animated/Controller.ts @@ -49,92 +49,94 @@ class Controller { id = nextId++ idle = true props: UpdateProps = {} + queue: any[] = [] timestamps: { [key: string]: number } = {} values: State = {} as any merged: State = {} as any animated: AnimatedMap = {} animations: AnimationMap = {} configs: Animation[] = [] - queue: any[] = [] - prevQueue: any[] = [] onEndQueue: OnEnd[] = [] runCount = 0 getValues = () => this.animated as { [K in keyof State]: Animation['animated'] } + constructor(props?: UpdateProps) { + if (props) this.update(props).start() + } + /** - * Update the controller by merging the given props into an array of tasks. - * Individual tasks may be async and/or delayed. + * 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 - let { delay = 0, to, ...props } = interpolateTo(propsArg) as any + const props = interpolateTo(propsArg) as any + props.timestamp = now() // For async animations, the `from` prop must be defined for // the Animated nodes to exist before animations have started. - if (props.from) { - for (const key in props.from) { - this._ensureAnimated(key, props.from[key]) - } + this._ensureAnimated(props.from) + if (is.obj(props.to)) { + this._ensureAnimated(props.to) } - if (is.arr(to) || is.fun(to)) { - if (is.num(delay) && delay < 0) delay = 0 - this.queue.push({ - ...props, - timestamp: now(), - delay, - to, - }) - } else if (to) { - // Compute the delay of each key - const ops: any[] = [] - for (const key in to) { - this._ensureAnimated(key, to[key]) - - // Merge entries with the same delay - const delay = Math.max(0, callProp(propsArg.delay, key) || 0) - const previous = ops[delay] || emptyObj - ops[delay] = { - ...previous, + // 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, - timestamp: now(), - delay, - to: { ...previous.to, [key]: to[key] }, - } + 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))), + }) } - ops.forEach(op => this.queue.push(op)) + } else { + props.delay = is.num(props.delay) + ? Math.max(0, Math.round(props.delay)) + : 0 + this.queue.push(props) } - return this } /** - * Execute any queued updates, else make sure the frameloop is running. - * The `useSpring` hooks never have > 1 update per call, because they call this every render. + * Flush the update queue. + * If the queue is empty, try starting the frameloop. */ start(onEnd?: OnEnd) { - // Apply any queued updates if (this.queue.length) this._flush(onEnd) - // ...or start the frameloop else this._start(onEnd) - return this } - // Clear all animations - stop(finished?: boolean) { - if (this.runCount) { - this.runCount = 0 - this.configs = [] - this.animations = {} + /** Stop one animation or all animations */ + stop(finished?: boolean): this + stop(key: string, finished?: boolean): this + stop(arg1?: string | boolean, finished = arg1 as boolean) { + // Stop one animation + if (is.str(arg1)) { + const index = this.configs.findIndex(config => arg1 === config.key) + this._stopAnimation(arg1) + this.configs[index] = this.animations[arg1] + } + // Stop all animations + else if (this.runCount) { + // Stop every async animation + this.animations = { ...this.animations } + // Update the animation configs + this.configs.forEach(config => this._stopAnimation(config.key)) + this.configs = Object.values(this.animations) + // Exit the frameloop this._stop(finished) } return this } - // Called by the frameloop + /** @internal Called by the frameloop */ onFrame(isActive: boolean) { if (this.props.onFrame) { this.props.onFrame(this.values) @@ -144,45 +146,45 @@ class Controller { } } + /** Reset the internal state */ destroy() { this.stop() - this.props = {} as any + this.props = {} this.timestamps = {} this.values = {} as any this.merged = {} as any - this.animated = {} as any + this.animated = {} this.animations = {} this.configs = [] } // Create an Animated node if none exists. - private _ensureAnimated(key: string, value: any) { - if (this.animated[key]) return - const animated = createAnimated(value) - if (!animated) { - return console.warn('Given value not animatable:', value) - } - - // Every `animated` needs an `animation` object - const animation: any = { - animated, - animatedValues: toArray(animated.getPayload()), + 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) + } } + } - this.animated[key] = animated - this.animations[key] = animation + // 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 (this.configs.length) { - if (onEnd) this.onEndQueue.push(onEnd) - if (this.idle) { - this.idle = false - start(this) - } - } else if (onEnd) { - onEnd(true) + if (onEnd) this._onEnd(onEnd) + if (this.idle && this.runCount) { + this.idle = false + start(this) } } @@ -200,13 +202,11 @@ class Controller { // Execute the current queue of prop updates. private _flush(onEnd?: OnEnd) { - const { prevQueue } = this - const queue = (this.prevQueue = this.queue) - this.queue = prevQueue - prevQueue.length = 0 + const queue = this.queue.reduce(reduceDelays, []) + this.queue.length = 0 // Track the number of running animations. - let runsLeft = queue.length + let runsLeft = Object.keys(queue).length this.runCount += runsLeft // Never assume that the last update always finishes last, since that's @@ -215,66 +215,77 @@ class Controller { this.runCount-- if (--runsLeft) return if (onEnd) onEnd(finished) - if (!this.runCount) { + if (!this.runCount && finished) { const { onRest } = this.props - if (onRest && finished) { - onRest(this.merged) - } + if (onRest) onRest(this.merged) } } - queue.forEach(({ delay, ...props }) => { + queue.forEach((props, delay) => { if (delay) setTimeout(() => this._run(props, onRunEnd), delay) else this._run(props, onRunEnd) }) } - private _run(props: UpdateProps, onEnd?: OnEnd) { - if (this._diff(props)) { - this._animate(this.props) - if (is.arr(props.to) || is.fun(props.to)) { - this._runAsync(props, onEnd) - } else { - this._start(onEnd) - } + // Update the props and animations + private _run(props: UpdateProps, onEnd: OnEnd) { + if (is.arr(props.to) || is.fun(props.to)) { + this._runAsync(props, onEnd) + } else if (this._diff(props)) { + this._animate(this.props)._start(onEnd) + } else { + this._onEnd(onEnd) } } // Start an async chain or an async script. - private _runAsync({ to }: UpdateProps, onEnd?: OnEnd) { - const { animations } = this + 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) + } - // The `stop` and `destroy` methods clear the animation map. - const isCancelled = () => animations !== this.animations + // 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 = (asyncProps: SpringProps) => { + const next = (props: UpdateProps) => { if (isCancelled()) throw this - if (to !== this.props.to) return return (last = new Promise(done => { - this.update(asyncProps).start(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(() => to(next, this.stop.bind(this)).then(() => last)) + queue = queue.then(() => + to(next, this.stop.bind(this)) + // Always wait for the last update. + .then(() => last) + ) } - queue = queue.catch(err => { - // "this" is thrown when the animation is cancelled - if (err !== this) console.error(err) - }) - - if (onEnd) { - queue.then(() => onEnd(!isCancelled())) - } + queue + .catch(err => err !== this && console.error(err)) + .then(() => onEnd(!isCancelled())) } - // Merge every fresh prop. Return false if no props were fresh. - private _diff({ timestamp, ...props }: UpdateProps): boolean { + // Merge every fresh prop. Returns true if one or more props changed. + private _diff({ timestamp, config, ...props }: UpdateProps) { let changed = false // Ensure the newer timestamp is used. @@ -288,15 +299,16 @@ class Controller { } // Generalized diffing algorithm - const diffProp = (keys: string[], value: unknown, parent: any) => { + 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.und(parent[lastKey])) parent[lastKey] = {} + 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('.'))) { - let oldValue = parent[lastKey] + const oldValue = parent[lastKey] if (!is.equ(value, oldValue)) { changed = true parent[lastKey] = value @@ -304,23 +316,28 @@ class Controller { } } + // The `config` prop is atomic + if (config && diffTimestamp('config')) { + changed = true + this.props.config = config + } + for (const key in props) { - diffProp([key], (props as any)[key], this.props) + diffProp([key], props[key], this.props) } return changed } // Update the animation configs. - private _animate({ - to = emptyObj, - from = emptyObj, - config = emptyObj, - reverse, - attach, - reset, - immediate, - onStart, - }: UpdateProps) { + private _animate(props: UpdateProps) { + let { to = emptyObj, from = emptyObj } = props + + // Reverse values when requested + if (props.reverse) [from, to] = [to, from] + + // Merge `from` values with `to` values + this.merged = { ...from, ...to } + // True if any animation was updated let changed = false @@ -328,17 +345,11 @@ class Controller { const started: string[] = [] // Attachment handling, trailed springs can "attach" themselves to a previous spring - const target = attach && attach(this) - - // Reverse values when requested - if (reverse) [from, to] = [to as any, from] - - // Merge `from` values with `to` values - this.merged = { ...from, ...this.merged, ...to } + const target = props.attach && props.attach(this) // Reduces input { key: value } pairs into animation objects for (const key in this.merged) { - const state = this.animations[key as any] + const state = this.animations[key] if (!state) { console.warn( `Failed to animate key: "${key}"\n` + @@ -351,35 +362,29 @@ class Controller { let { animated, animatedValues } = state const value = this.merged[key] - const goalValue = computeGoalValue(value) - - // The animation is done already if the current value is the goal value. - if (!reset && is.equ(goalValue, animated.getValue())) { - changed = true + const goalValue = computeGoalValue(value) - const values = toArray(goalValue) - animatedValues.forEach((animated, i) => { - animated.done = true - if (isAnimatableString(values[i])) { - animated.setValue(1, false) - } - }) - - this.animations[key] = { ...state, key, goalValue } + // Stop animations with a goal value equal to its current value. + if (!props.reset && is.equ(goalValue, animated.getValue())) { + // The animation might be stopped already. + if (!is.und(state.goalValue)) { + changed = true + this._stopAnimation(key) + } continue } - const newConfig = callProp(config, key) + const config = callProp(props.config, key) || emptyObj // Animations are only updated when they were reset, they have a new // goal value, or their spring config was changed. if ( - reset || + props.reset || !is.equ(goalValue, state.goalValue) || - !is.equ(newConfig, state.config) + !is.equ(config, state.config) ) { - const isImmediate = callProp(immediate, key) - if (!isImmediate) started.push(key) + const immediate = callProp(props.immediate, key) + if (!immediate) started.push(key) const isActive = animatedValues.some(v => !v.done) const fromValue = !is.und(from[key]) @@ -394,7 +399,7 @@ class Controller { if (animated instanceof AnimatedInterpolation) { input = animatedValues[0] - if (!reset) output[0] = animated.calc(input.value) + if (!props.reset) output[0] = animated.calc(input.value) animated.updateConfig({ output }) input.setValue(0, false) @@ -403,7 +408,7 @@ class Controller { input = new AnimatedValue(0) animated = input.interpolate({ output }) } - if (isImmediate) { + if (immediate) { input.setValue(1, false) } } else { @@ -421,8 +426,8 @@ class Controller { animated = new AnimatedValue(fromValue) } } - if (reset || isImmediate) { - animated.setValue(isImmediate ? goalValue : fromValue, false) + if (props.reset || immediate) { + animated.setValue(immediate ? goalValue : fromValue, false) } } @@ -441,26 +446,30 @@ class Controller { fromValues: animatedValues.map(v => v.getValue()), animated, animatedValues, - immediate: isImmediate, - duration: newConfig.duration, - easing: withDefault(newConfig.easing, linear), - decay: newConfig.decay, - mass: withDefault(newConfig.mass, 1), - tension: withDefault(newConfig.tension, 170), - friction: withDefault(newConfig.friction, 26), - initialVelocity: withDefault(newConfig.velocity, 0), - clamp: withDefault(newConfig.clamp, false), - precision: withDefault(newConfig.precision, 0.01), - config: newConfig, + 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 (onStart && started.length) { - started.forEach(key => onStart!(this.animations[key])) + if (props.onStart && started.length) { + started.forEach(key => props.onStart!(this.animations[key])) } + // Reset any flags + props.reset = false + props.immediate = false + // Make animations available to the frameloop const configs = (this.configs = [] as Animation[]) const values = (this.values = {} as any) @@ -474,23 +483,30 @@ class Controller { } return this } -} -// 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] -} + // Stop an animation by its key + private _stopAnimation(key: string) { + if (!this.animated[key]) return -// 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 + const state = this.animations[key] + if (state && is.und(state.goalValue)) return + + let { animated, animatedValues } = state || emptyObj + if (!state) { + animated = this.animated[key] + animatedValues = toArray(animated.getPayload() as any) + } + + this.animations[key] = { key, animated, animatedValues } as any + animatedValues.forEach(v => (v.done = true)) + + // 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 @@ -501,7 +517,9 @@ function createAnimated( ? new AnimatedValueArray( value.map(fromValue => { const animated = createAnimated(fromValue)! - if (!animated) console.warn('Given value not animatable:', fromValue) + if (!animated) { + console.warn('Given value not animatable:', fromValue) + } return animated instanceof AnimatedValue ? animated : (animated.getPayload() as any) @@ -516,4 +534,35 @@ function createAnimated( : null } -export default Controller +// Merge updates with the same delay. +// NOTE: Mutation can occur! +function reduceDelays(merged: any[], props: any) { + const prev = merged[props.delay] + if (prev) { + props.to = merge(prev.to, props.to) + props.from = merge(prev.from, props.from) + Object.assign(prev, props) + } else { + merged[props.delay] = props + } + return merged +} + +function merge(dest: any, src: any) { + return is.obj(dest) && is.obj(src) ? { ...dest, ...src } : src || dest +} + +// Not all strings can be animated (eg: {display: "none"}) +function isAnimatableString(value: unknown): boolean { + if (!is.str(value)) return false + return value.startsWith('#') || /\d/.test(value) || !!colorNames[value] +} + +// Compute the goal value, converting "red" to "rgba(255, 0, 0, 1)" in the process +function computeGoalValue(value: T): T { + return is.arr(value) + ? value.map(computeGoalValue) + : isAnimatableString(value) + ? (interp as any)({ range: [0, 1], output: [value, value] })(1) + : value +} diff --git a/src/animated/FrameLoop.ts b/src/animated/FrameLoop.ts index 509be26081..76b5166d8a 100644 --- a/src/animated/FrameLoop.ts +++ b/src/animated/FrameLoop.ts @@ -114,7 +114,7 @@ const update = () => { // Keep track of updated values only when necessary if (controller.props.onFrame) { - controller.frames[config.name] = config.node.getValue() + controller.values[config.name] = config.animated.getValue() } } diff --git a/src/shared/helpers.ts b/src/shared/helpers.ts index 517100d58f..4c1c997a5f 100644 --- a/src/shared/helpers.ts +++ b/src/shared/helpers.ts @@ -2,7 +2,7 @@ import { MutableRefObject, Ref, useCallback, useState } from 'react' export const is = { arr: Array.isArray, - obj: (a: unknown): a is { [key: string]: any } => + 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', @@ -120,14 +120,17 @@ interface InterpolateTo extends PartialExcludedProps { export function interpolateTo( props: T ): InterpolateTo { - const forward: ForwardedProps = getForwardProps(props) - if (is.und(forward)) return { to: forward, ...props } - const rest = Object.keys(props).reduce( - (a: PartialExcludedProps, k: string) => - !is.und((forward as any)[k]) ? a : { ...a, [k]: (props as any)[k] }, - {} + const forward = getForwardProps(props) + props = Object.entries(props).reduce( + (props, [key, value]) => (key in forward || (props[key] = value), props), + {} as any ) - return { to: forward, ...rest } + return { to: forward, ...props } +} + +export function isEmptyObj(obj: object) { + for (const _ in obj) return false + return true } export function handleRef(ref: T, forward: Ref) { diff --git a/src/useSprings.js b/src/useSprings.js index f8949f6dda..d0b38eeaf3 100644 --- a/src/useSprings.js +++ b/src/useSprings.js @@ -14,20 +14,20 @@ export const useSprings = (length, props) => { // The controller maintains the animation values, starts and stops animations const [controllers, setProps, ref, api] = useMemo(() => { - let ref + let ref, controllers return [ // Recreate the controllers whenever `length` changes - fillArray(length, i => { + (controllers = fillArray(length, i => { const c = new Ctrl() const newProps = isFn ? callProp(props, i, c) : props[i] if (i === 0) ref = newProps.ref return c.update(newProps) - }), + })), // This updates the controllers with new props props => { const isFn = is.fun(props) if (!isFn) props = toArray(props) - ctrl.current.forEach((c, i) => { + controllers.forEach((c, i) => { c.update(isFn ? callProp(props, i, c) : props[i]) if (!ref) c.start() }) @@ -36,11 +36,9 @@ export const useSprings = (length, props) => { ref, 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 - }, + Promise.all(controllers.map(c => new Promise(r => c.start(r)))), + stop: finished => controllers.forEach(c => c.stop(finished)), + controllers, }, ] }, [length]) @@ -73,7 +71,7 @@ export const useSprings = (length, props) => { ? [ values, setProps, - finished => ctrl.current.forEach(c => c.stop(finished)), + (key, finished) => ctrl.current.forEach(c => c.stop(key, finished)), ] : values } diff --git a/src/useTransition.js b/src/useTransition.js index 0cce33180e..ca458f011f 100644 --- a/src/useTransition.js +++ b/src/useTransition.js @@ -23,21 +23,22 @@ import { requestFrame } from './animated/Globals' let guid = 0 +const INITIAL = 'initial' const ENTER = 'enter' -const LEAVE = 'leave' const UPDATE = 'update' +const LEAVE = 'leave' const makeKeys = (items, keys) => (typeof keys === 'function' ? items.map(keys) : toArray(keys)).map(String) const makeConfig = props => { - let { items, keys = item => item, ...rest } = 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, props) { - const config = makeConfig({ + props = makeConfig({ ...props, items: input, keys: keyTransform || (i => i), @@ -58,7 +59,7 @@ export function useTransition(input, keyTransform, props) { onStart, ref, ...extra - } = config + } = props const forceUpdate = useForceUpdate() const mounted = useRef(false) @@ -68,7 +69,7 @@ export function useTransition(input, keyTransform, props) { deleted: [], current: {}, transitions: [], - prevProps: emptyObj, + prevProps: {}, paused: !!props.ref, instances: !mounted.current && new Map(), forceUpdate, @@ -89,11 +90,11 @@ export function useTransition(input, keyTransform, props) { })) // Update state - state.current = diffItems(state.current, config) + state.current = diffItems(state.current, props) if (state.current.changed) { // Update state state.current.transitions.forEach(transition => { - const { phase, spring, key, item } = transition + const { phase, key, item, props } = transition if (!state.current.instances.has(key)) state.current.instances.set(key, new Ctrl()) @@ -105,8 +106,7 @@ export function useTransition(input, keyTransform, props) { const itemProps = { reset: reset && phase === ENTER, ...extra, - ...spring, - from: callProp(from, item), + ...props, ref, onRest: values => { if (state.current.mounted) { @@ -171,8 +171,6 @@ function cleanUp({ current: state }, filterKey) { state.forceUpdate() } -const emptyObj = Object.freeze({}) - function diffItems({ first, current, deleted, prevProps, ...state }, props) { let { items, @@ -190,98 +188,82 @@ function diffItems({ first, current, deleted, prevProps, ...state }, 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(key => !currentSet.has(key)) - let removed = state.transitions + const currentKeys = Object.keys(current) + const currentSet = new Set(currentKeys) + const nextSet = new Set(keys) + + const addedKeys = keys.filter(key => !currentSet.has(key)) + const updatedKeys = update ? keys.filter(key => currentSet.has(key)) : [] + const deletedKeys = state.transitions .filter(t => !t.destroyed && !nextSet.has(t.originalKey)) .map(t => t.originalKey) - let updated = keys.filter(key => currentSet.has(key)) + 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 phase = first && initial !== void 0 ? 'initial' : ENTER - const enterProps = interpolateTo( - callProp(enter, item, keyIndex) || emptyObj - ) - current[key] = { - phase, - originalKey: key, - key: unique ? String(key) : guid++, - item, - spring: { - delay: is.und(enterProps.delay) - ? (delay += trail) - : enterProps.delay, - config: enterProps.config || callProp(config, item, phase), - from: - callProp(first && !is.und(initial) ? initial : from, item) || - emptyObj, - ...enterProps, - }, - } - }) - break - } - case LEAVE: { - removed.forEach(key => { - const keyIndex = _keys.indexOf(key) - const item = _items[keyIndex] - const phase = LEAVE - const leaveProps = interpolateTo( - callProp(leave, item, keyIndex) || emptyObj - ) - deleted.unshift({ - ...current[key], - phase, - destroyed: true, - left: _keys[Math.max(0, keyIndex - 1)], - right: _keys[Math.min(_keys.length, keyIndex + 1)], - spring: { - delay: is.und(leaveProps.delay) - ? (delay += trail) - : leaveProps.delay, - config: leaveProps.config || callProp(config, item, phase), - ...leaveProps, - }, - }) - delete current[key] - }) - break + let phase = order.shift() + if (phase === ENTER) { + if (first && !is.und(initial)) { + phase = INITIAL } - case UPDATE: { - updated.forEach(key => { - const keyIndex = keys.indexOf(key) - const item = items[keyIndex] - const phase = UPDATE - const updateProps = interpolateTo( - callProp(update, item, keyIndex) || emptyObj - ) - current[key] = { - ...current[key], - phase, - spring: { - delay: is.und(updateProps.delay) - ? (delay += trail) - : updateProps.delay, - config: updateProps.config || callProp(config, item, phase), - ...updateProps, - }, - } + 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 - } + 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]) @@ -299,8 +281,8 @@ function diffItems({ first, current, deleted, prevProps, ...state }, props) { return { ...state, - first: first && !!added.length, - changed: !!(added.length || removed.length || updated.length), + first: first && !addedKeys.length, + changed: !!(addedKeys.length || deletedKeys.length || updatedKeys.length), transitions: out, current, deleted,