You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
694 lines
22 KiB
694 lines
22 KiB
// @flow |
|
/** |
|
* React Flip Move |
|
* (c) 2016-present Joshua Comeau |
|
* |
|
* For information on how this code is laid out, check out CODE_TOUR.md |
|
*/ |
|
|
|
/* eslint-disable react/prop-types */ |
|
|
|
import React, { |
|
Component, |
|
Element, |
|
Children, |
|
} from 'react'; |
|
|
|
import './polyfills'; |
|
import propConverter from './prop-converter'; |
|
import { |
|
applyStylesToDOMNode, |
|
createTransitionString, |
|
getNativeNode, |
|
getPositionDelta, |
|
getRelativeBoundingBox, |
|
removeNodeFromDOMFlow, |
|
updateHeightPlaceholder, |
|
whichTransitionEvent, |
|
} from './dom-manipulation'; |
|
import { arraysEqual } from './helpers'; |
|
import type { |
|
ConvertedProps, |
|
FlipMoveState, |
|
ElementShape, |
|
ChildrenHook, |
|
ChildData, |
|
NodeData, |
|
DelegatedProps, |
|
Styles, |
|
} from './typings'; |
|
|
|
const transitionEnd = whichTransitionEvent(); |
|
const noBrowserSupport = !transitionEnd; |
|
|
|
function getKey(childData: ChildData): string { |
|
return childData.key || ''; |
|
} |
|
|
|
class FlipMove extends Component<void, ConvertedProps, FlipMoveState> { |
|
// Copy props.children into state. |
|
// To understand why this is important (and not an anti-pattern), consider |
|
// how "leave" animations work. An item has "left" when the component |
|
// receives a new set of props that do NOT contain the item. |
|
// If we just render the props as-is, the item would instantly disappear. |
|
// We want to keep the item rendered for a little while, until its animation |
|
// can complete. Because we cannot mutate props, we make `state` the source |
|
// of truth. |
|
state = { |
|
children: Children.toArray(this.props.children).map((element: Element<*>) => ({ |
|
...element, |
|
element, |
|
appearing: true, |
|
})), |
|
}; |
|
|
|
// FlipMove needs to know quite a bit about its children in order to do |
|
// its job. We store these as a property on the instance. We're not using |
|
// state, because we don't want changes to trigger re-renders, we just |
|
// need a place to keep the data for reference, when changes happen. |
|
// This field should not be accessed directly. Instead, use getChildData, |
|
// putChildData, etc... |
|
childrenData: { |
|
/* Populated via callback refs on render. eg |
|
userSpecifiedKey1: { |
|
domNode: <domNode>, |
|
boundingBox: { top, left, right, bottom, width, height }, |
|
}, |
|
userSpecifiedKey2: { ... }, |
|
... |
|
*/ |
|
[userSpecifiedKey: string]: NodeData, |
|
} = {}; |
|
|
|
// Similarly, track the dom node and box of our parent element. |
|
parentData: NodeData = { |
|
domNode: null, |
|
boundingBox: null, |
|
}; |
|
|
|
// If `maintainContainerHeight` prop is set to true, we'll create a |
|
// placeholder element which occupies space so that the parent height |
|
// doesn't change when items are removed from the document flow (which |
|
// happens during leave animations) |
|
heightPlaceholderData: NodeData = { |
|
domNode: null, |
|
}; |
|
|
|
|
|
// Keep track of remaining animations so we know when to fire the |
|
// all-finished callback, and clean up after ourselves. |
|
// NOTE: we can't simply use childrenToAnimate.length to track remaining |
|
// animations, because we need to maintain the list of animating children, |
|
// to pass to the `onFinishAll` handler. |
|
remainingAnimations = 0; |
|
childrenToAnimate: Array<string> = []; |
|
|
|
componentDidMount() { |
|
// Run our `appearAnimation` if it was requested, right after the |
|
// component mounts. |
|
const shouldTriggerFLIP = ( |
|
this.props.appearAnimation && |
|
!this.isAnimationDisabled(this.props) |
|
); |
|
|
|
if (shouldTriggerFLIP) { |
|
this.prepForAnimation(); |
|
this.runAnimation(); |
|
} |
|
} |
|
|
|
componentWillReceiveProps(nextProps: ConvertedProps) { |
|
// When the component is handed new props, we need to figure out the |
|
// "resting" position of all currently-rendered DOM nodes. |
|
// We store that data in this.parent and this.children, |
|
// so it can be used later to work out the animation. |
|
this.updateBoundingBoxCaches(); |
|
|
|
// Convert opaque children object to array. |
|
const nextChildren: Array<Element<*>> = Children.toArray(nextProps.children); |
|
|
|
// Next, we need to update our state, so that it contains our new set of |
|
// children. If animation is disabled or unsupported, this is easy; |
|
// we just copy our props into state. |
|
// Assuming that we can animate, though, we have to do some work. |
|
// Essentially, we want to keep just-deleted nodes in the DOM for a bit |
|
// longer, so that we can animate them away. |
|
this.setState({ |
|
children: this.isAnimationDisabled(nextProps) |
|
? nextChildren.map(element => ({ ...element, element })) |
|
: this.calculateNextSetOfChildren(nextChildren), |
|
}); |
|
} |
|
|
|
componentDidUpdate(previousProps: ConvertedProps) { |
|
// If the children have been re-arranged, moved, or added/removed, |
|
// trigger the main FLIP animation. |
|
// |
|
// IMPORTANT: We need to make sure that the children have actually changed. |
|
// At the end of the transition, we clean up nodes that need to be removed. |
|
// We DON'T want this cleanup to trigger another update. |
|
|
|
const oldChildrenKeys: Array<string> = Children.toArray(this.props.children).map(d => d.key); |
|
const nextChildrenKeys: Array<string> = |
|
Children.toArray(previousProps.children).map(d => d.key); |
|
|
|
const shouldTriggerFLIP = ( |
|
!arraysEqual(oldChildrenKeys, nextChildrenKeys) && |
|
!this.isAnimationDisabled(this.props) |
|
); |
|
|
|
if (shouldTriggerFLIP) { |
|
this.prepForAnimation(); |
|
this.runAnimation(); |
|
} |
|
} |
|
|
|
runAnimation = () => { |
|
const dynamicChildren = this.state.children.filter( |
|
this.doesChildNeedToBeAnimated |
|
); |
|
|
|
dynamicChildren.forEach((child, n) => { |
|
this.remainingAnimations += 1; |
|
this.childrenToAnimate.push(getKey(child)); |
|
this.animateChild(child, n); |
|
}); |
|
|
|
if (typeof this.props.onStartAll === 'function') { |
|
this.callChildrenHook(this.props.onStartAll); |
|
} |
|
}; |
|
|
|
doesChildNeedToBeAnimated = (child: ChildData) => { |
|
// If the child doesn't have a key, it's an immovable child (one that we |
|
// do not want to do FLIP stuff to.) |
|
if (!getKey(child)) { |
|
return false; |
|
} |
|
|
|
const childData: NodeData = this.getChildData(getKey(child)); |
|
const childDomNode = childData.domNode; |
|
const childBoundingBox = childData.boundingBox; |
|
const parentBoundingBox = this.parentData.boundingBox; |
|
|
|
if (!childDomNode) { |
|
return false; |
|
} |
|
|
|
const { appearAnimation, enterAnimation, leaveAnimation, getPosition } = this.props; |
|
|
|
const isAppearingWithAnimation = child.appearing && appearAnimation; |
|
const isEnteringWithAnimation = child.entering && enterAnimation; |
|
const isLeavingWithAnimation = child.leaving && leaveAnimation; |
|
|
|
if ( |
|
isAppearingWithAnimation || |
|
isEnteringWithAnimation || |
|
isLeavingWithAnimation |
|
) { |
|
return true; |
|
} |
|
|
|
// If it isn't entering/leaving, we want to animate it if it's |
|
// on-screen position has changed. |
|
const [dX, dY] = getPositionDelta({ |
|
childDomNode, |
|
childBoundingBox, |
|
parentBoundingBox, |
|
getPosition, |
|
}); |
|
|
|
return dX !== 0 || dY !== 0; |
|
}; |
|
|
|
calculateNextSetOfChildren(nextChildren: Array<Element<*>>): Array<ChildData> { |
|
// We want to: |
|
// - Mark all new children as `entering` |
|
// - Pull in previous children that aren't in nextChildren, and mark them |
|
// as `leaving` |
|
// - Preserve the nextChildren list order, with leaving children in their |
|
// appropriate places. |
|
// |
|
|
|
// Start by marking new children as 'entering' |
|
const updatedChildren: Array<ChildData> = nextChildren.map((nextChild) => { |
|
const child = this.findChildByKey(nextChild.key || ''); |
|
|
|
// If the current child did exist, but it was in the midst of leaving, |
|
// we want to treat it as though it's entering |
|
const isEntering = !child || child.leaving; |
|
|
|
return { ...nextChild, element: nextChild, entering: isEntering }; |
|
}); |
|
|
|
// This is tricky. We want to keep the nextChildren's ordering, but with |
|
// any just-removed items maintaining their original position. |
|
// eg. |
|
// this.state.children = [ 1, 2, 3, 4 ] |
|
// nextChildren = [ 3, 1 ] |
|
// |
|
// In this example, we've removed the '2' & '4' |
|
// We want to end up with: [ 2, 3, 1, 4 ] |
|
// |
|
// To accomplish that, we'll iterate through this.state.children. whenever |
|
// we find a match, we'll append our `leaving` flag to it, and insert it |
|
// into the nextChildren in its ORIGINAL position. Note that, as we keep |
|
// inserting old items into the new list, the "original" position will |
|
// keep incrementing. |
|
let numOfChildrenLeaving = 0; |
|
this.state.children.forEach((child: ChildData, index) => { |
|
const isLeaving = !nextChildren.find(({ key }) => key === getKey(child)); |
|
|
|
// If the child isn't leaving (or, if there is no leave animation), |
|
// we don't need to add it into the state children. |
|
if (!isLeaving || !this.props.leaveAnimation) return; |
|
|
|
const nextChild: ChildData = { ...child, leaving: true }; |
|
const nextChildIndex = index + numOfChildrenLeaving; |
|
|
|
updatedChildren.splice(nextChildIndex, 0, nextChild); |
|
numOfChildrenLeaving += 1; |
|
}); |
|
|
|
return updatedChildren; |
|
} |
|
|
|
prepForAnimation() { |
|
// Our animation prep consists of: |
|
// - remove children that are leaving from the DOM flow, so that the new |
|
// layout can be accurately calculated, |
|
// - update the placeholder container height, if needed, to ensure that |
|
// the parent's height doesn't collapse. |
|
|
|
const { |
|
leaveAnimation, |
|
maintainContainerHeight, |
|
getPosition, |
|
} = this.props; |
|
|
|
// we need to make all leaving nodes "invisible" to the layout calculations |
|
// that will take place in the next step (this.runAnimation). |
|
if (leaveAnimation) { |
|
const leavingChildren = this.state.children.filter(child => ( |
|
child.leaving |
|
)); |
|
|
|
leavingChildren.forEach((leavingChild) => { |
|
const childData = this.getChildData(getKey(leavingChild)); |
|
|
|
// We need to take the items out of the "flow" of the document, so that |
|
// its siblings can move to take its place. |
|
if (childData.boundingBox) { |
|
removeNodeFromDOMFlow(childData, this.props.verticalAlignment); |
|
} |
|
}); |
|
|
|
if (maintainContainerHeight && this.heightPlaceholderData.domNode) { |
|
updateHeightPlaceholder({ |
|
domNode: this.heightPlaceholderData.domNode, |
|
parentData: this.parentData, |
|
getPosition, |
|
}); |
|
} |
|
} |
|
|
|
// For all children not in the middle of entering or leaving, |
|
// we need to reset the transition, so that the NEW shuffle starts from |
|
// the right place. |
|
this.state.children.forEach((child) => { |
|
const { domNode } = this.getChildData(getKey(child)); |
|
|
|
// Ignore children that don't render DOM nodes (eg. by returning null) |
|
if (!domNode) { |
|
return; |
|
} |
|
|
|
if (!child.entering && !child.leaving) { |
|
applyStylesToDOMNode({ |
|
domNode, |
|
styles: { |
|
transition: '', |
|
}, |
|
}); |
|
} |
|
}); |
|
} |
|
|
|
animateChild(child: ChildData, index: number) { |
|
const { domNode } = this.getChildData(getKey(child)); |
|
if (!domNode) { |
|
return; |
|
} |
|
|
|
// Apply the relevant style for this DOM node |
|
// This is the offset from its actual DOM position. |
|
// eg. if an item has been re-rendered 20px lower, we want to apply a |
|
// style of 'transform: translate(-20px)', so that it appears to be where |
|
// it started. |
|
// In FLIP terminology, this is the 'Invert' stage. |
|
applyStylesToDOMNode({ |
|
domNode, |
|
styles: this.computeInitialStyles(child), |
|
}); |
|
|
|
// Start by invoking the onStart callback for this child. |
|
if (this.props.onStart) this.props.onStart(child, domNode); |
|
|
|
// Next, animate the item from it's artificially-offset position to its |
|
// new, natural position. |
|
requestAnimationFrame(() => { |
|
requestAnimationFrame(() => { |
|
// NOTE, RE: the double-requestAnimationFrame: |
|
// Sadly, this is the most browser-compatible way to do this I've found. |
|
// Essentially we need to set the initial styles outside of any request |
|
// callbacks to avoid batching them. Then, a frame needs to pass with |
|
// the styles above rendered. Then, on the second frame, we can apply |
|
// our final styles to perform the animation. |
|
|
|
// Our first order of business is to "undo" the styles applied in the |
|
// previous frames, while also adding a `transition` property. |
|
// This way, the item will smoothly transition from its old position |
|
// to its new position. |
|
|
|
// eslint-disable-next-line flowtype/require-variable-type |
|
let styles = { |
|
transition: createTransitionString(index, this.props), |
|
transform: '', |
|
opacity: '', |
|
}; |
|
|
|
if (child.appearing && this.props.appearAnimation) { |
|
styles = { |
|
...styles, |
|
...this.props.appearAnimation.to, |
|
}; |
|
} else if (child.entering && this.props.enterAnimation) { |
|
styles = { |
|
...styles, |
|
...this.props.enterAnimation.to, |
|
}; |
|
} else if (child.leaving && this.props.leaveAnimation) { |
|
styles = { |
|
...styles, |
|
...this.props.leaveAnimation.to, |
|
}; |
|
} |
|
|
|
// In FLIP terminology, this is the 'Play' stage. |
|
applyStylesToDOMNode({ domNode, styles }); |
|
}); |
|
}); |
|
|
|
this.bindTransitionEndHandler(child); |
|
} |
|
|
|
bindTransitionEndHandler(child: ChildData) { |
|
const { domNode } = this.getChildData(getKey(child)); |
|
if (!domNode) { |
|
return; |
|
} |
|
|
|
// The onFinish callback needs to be bound to the transitionEnd event. |
|
// We also need to unbind it when the transition completes, so this ugly |
|
// inline function is required (we need it here so it closes over |
|
// dependent variables `child` and `domNode`) |
|
const transitionEndHandler = (ev: Event) => { |
|
// It's possible that this handler is fired not on our primary transition, |
|
// but on a nested transition (eg. a hover effect). Ignore these cases. |
|
if (ev.target !== domNode) return; |
|
|
|
// Remove the 'transition' inline style we added. This is cleanup. |
|
domNode.style.transition = ''; |
|
|
|
// Trigger any applicable onFinish/onFinishAll hooks |
|
this.triggerFinishHooks(child, domNode); |
|
|
|
domNode.removeEventListener(transitionEnd, transitionEndHandler); |
|
|
|
if (child.leaving) { |
|
this.removeChildData(getKey(child)); |
|
} |
|
}; |
|
|
|
domNode.addEventListener(transitionEnd, transitionEndHandler); |
|
} |
|
|
|
triggerFinishHooks(child: ChildData, domNode: HTMLElement) { |
|
if (this.props.onFinish) this.props.onFinish(child, domNode); |
|
|
|
// Reduce the number of children we need to animate by 1, |
|
// so that we can tell when all children have finished. |
|
this.remainingAnimations -= 1; |
|
|
|
if (this.remainingAnimations === 0) { |
|
// Remove any items from the DOM that have left, and reset `entering`. |
|
const nextChildren: Array<ChildData> = this.state.children |
|
.filter(({ leaving }) => !leaving) |
|
.map(item => ({ |
|
...item, |
|
appearing: false, |
|
entering: false, |
|
})); |
|
|
|
this.setState({ children: nextChildren }, () => { |
|
if (typeof this.props.onFinishAll === 'function') { |
|
this.callChildrenHook(this.props.onFinishAll); |
|
} |
|
|
|
// Reset our variables for the next iteration |
|
this.childrenToAnimate = []; |
|
}); |
|
|
|
// If the placeholder was holding the container open while elements were |
|
// leaving, we we can now set its height to zero. |
|
if (this.heightPlaceholderData.domNode) { |
|
this.heightPlaceholderData.domNode.style.height = '0'; |
|
} |
|
} |
|
} |
|
|
|
callChildrenHook(hook: ChildrenHook) { |
|
const elements: Array<ElementShape> = []; |
|
const domNodes: Array<?HTMLElement> = []; |
|
|
|
this.childrenToAnimate.forEach((childKey) => { |
|
// If this was an exit animation, the child may no longer exist. |
|
// If so, skip it. |
|
const child = this.findChildByKey(childKey); |
|
|
|
if (!child) { |
|
return; |
|
} |
|
|
|
elements.push(child); |
|
|
|
if (this.hasChildData(childKey)) { |
|
domNodes.push(this.getChildData(childKey).domNode); |
|
} |
|
}); |
|
|
|
hook(elements, domNodes); |
|
} |
|
|
|
updateBoundingBoxCaches() { |
|
// This is the ONLY place that parentData and childrenData's |
|
// bounding boxes are updated. They will be calculated at other times |
|
// to be compared to this value, but it's important that the cache is |
|
// updated once per update. |
|
const parentDomNode = this.parentData.domNode; |
|
|
|
if (!parentDomNode) { |
|
return; |
|
} |
|
|
|
this.parentData.boundingBox = this.props.getPosition( |
|
parentDomNode |
|
); |
|
|
|
this.state.children.forEach((child) => { |
|
const childKey = getKey(child); |
|
|
|
// It is possible that a child does not have a `key` property; |
|
// Ignore these children, they don't need to be moved. |
|
if (!childKey) { |
|
return; |
|
} |
|
|
|
// In very rare circumstances, for reasons unknown, the ref is never |
|
// populated for certain children. In this case, avoid doing this update. |
|
// see: https://github.com/joshwcomeau/react-flip-move/pull/91 |
|
if (!this.hasChildData(childKey)) { |
|
return; |
|
} |
|
|
|
const childData = this.getChildData(childKey); |
|
|
|
// If the child element returns null, we need to avoid trying to |
|
// account for it |
|
if (!childData.domNode || !child) { |
|
return; |
|
} |
|
|
|
this.setChildData(childKey, { |
|
boundingBox: getRelativeBoundingBox({ |
|
childDomNode: childData.domNode, |
|
parentDomNode, |
|
getPosition: this.props.getPosition, |
|
}), |
|
}); |
|
}); |
|
} |
|
|
|
computeInitialStyles(child: ChildData): Styles { |
|
if (child.appearing) { |
|
return this.props.appearAnimation |
|
? this.props.appearAnimation.from |
|
: {}; |
|
} else if (child.entering) { |
|
if (!this.props.enterAnimation) { |
|
return {}; |
|
} |
|
// If this child was in the middle of leaving, it still has its |
|
// absolute positioning styles applied. We need to undo those. |
|
return { |
|
position: '', |
|
top: '', |
|
left: '', |
|
right: '', |
|
bottom: '', |
|
...this.props.enterAnimation.from, |
|
}; |
|
} else if (child.leaving) { |
|
return this.props.leaveAnimation |
|
? this.props.leaveAnimation.from |
|
: {}; |
|
} |
|
|
|
const childData = this.getChildData(getKey(child)); |
|
const childDomNode = childData.domNode; |
|
const childBoundingBox = childData.boundingBox; |
|
const parentBoundingBox = this.parentData.boundingBox; |
|
|
|
if (!childDomNode) { |
|
return {}; |
|
} |
|
|
|
const [dX, dY] = getPositionDelta({ |
|
childDomNode, |
|
childBoundingBox, |
|
parentBoundingBox, |
|
getPosition: this.props.getPosition, |
|
}); |
|
|
|
return { |
|
transform: `translate(${dX}px, ${dY}px)`, |
|
}; |
|
} |
|
|
|
// eslint-disable-next-line class-methods-use-this |
|
isAnimationDisabled(props: ConvertedProps): boolean { |
|
// If the component is explicitly passed a `disableAllAnimations` flag, |
|
// we can skip this whole process. Similarly, if all of the numbers have |
|
// been set to 0, there is no point in trying to animate; doing so would |
|
// only cause a flicker (and the intent is probably to disable animations) |
|
// We can also skip this rigamarole if there's no browser support for it. |
|
return ( |
|
noBrowserSupport || |
|
props.disableAllAnimations || |
|
( |
|
props.duration === 0 && |
|
props.delay === 0 && |
|
props.staggerDurationBy === 0 && |
|
props.staggerDelayBy === 0 |
|
) |
|
); |
|
} |
|
|
|
findChildByKey(key: string): ?ChildData { |
|
return this.state.children.find(child => getKey(child) === key); |
|
} |
|
|
|
hasChildData(key: string): boolean { |
|
// Object has some built-in properties on its prototype, such as toString. hasOwnProperty makes |
|
// sure that key is present on childrenData itself, not on its prototype. |
|
return Object.prototype.hasOwnProperty.call(this.childrenData, key); |
|
} |
|
|
|
getChildData(key: string): NodeData { |
|
return this.hasChildData(key) ? this.childrenData[key] : {}; |
|
} |
|
|
|
setChildData(key: string, data: NodeData): void { |
|
this.childrenData[key] = { ...this.getChildData(key), ...data }; |
|
} |
|
|
|
removeChildData(key: string): void { |
|
delete this.childrenData[key]; |
|
} |
|
|
|
createHeightPlaceholder(): Element<*> { |
|
const { typeName } = this.props; |
|
|
|
// If requested, create an invisible element at the end of the list. |
|
// Its height will be modified to prevent the container from collapsing |
|
// prematurely. |
|
const isContainerAList = typeName === 'ul' || typeName === 'ol'; |
|
const placeholderType = isContainerAList ? 'li' : 'div'; |
|
|
|
return React.createElement( |
|
placeholderType, |
|
{ |
|
key: 'height-placeholder', |
|
ref: (domNode: ?HTMLElement) => { this.heightPlaceholderData.domNode = domNode; }, |
|
style: { visibility: 'hidden', height: 0 }, |
|
} |
|
); |
|
} |
|
|
|
childrenWithRefs(): Array<Element<*>> { |
|
// We need to clone the provided children, capturing a reference to the |
|
// underlying DOM node. Flip Move needs to use the React escape hatches to |
|
// be able to do its calculations. |
|
return this.state.children.map(child => ( |
|
React.cloneElement(child.element, { |
|
ref: (element: HTMLElement | Component<*, *, *>) => { |
|
// Stateless Functional Components are not supported by FlipMove, |
|
// because they don't have instances. |
|
if (!element) { |
|
return; |
|
} |
|
|
|
const domNode: ?HTMLElement = getNativeNode(element); |
|
this.setChildData(getKey(child), { domNode }); |
|
}, |
|
}) |
|
)); |
|
} |
|
|
|
render() { |
|
const { |
|
typeName, |
|
delegated, |
|
leaveAnimation, |
|
maintainContainerHeight, |
|
} = this.props; |
|
|
|
const props: DelegatedProps = { |
|
...delegated, |
|
ref: (node: ?HTMLElement) => { this.parentData.domNode = node; }, |
|
}; |
|
|
|
const children = this.childrenWithRefs(); |
|
if (leaveAnimation && maintainContainerHeight) { |
|
children.push(this.createHeightPlaceholder()); |
|
} |
|
|
|
return React.createElement( |
|
typeName, |
|
props, |
|
children |
|
); |
|
} |
|
} |
|
|
|
export default propConverter(FlipMove);
|
|
|