@spotware/react-collapse
Version:
Component-wrapper for collapse animation with react-motion for elements with variable (and dynamic) height
258 lines (203 loc) • 6.42 kB
JavaScript
/* eslint-disable react/no-did-update-set-state,react/no-did-mount-set-state */
import React from 'react';
import PropTypes from 'prop-types';
import {Motion, spring} from 'react-motion';
const SPRING_PRECISION = 1;
const WAITING = 'WAITING';
const RESIZING = 'RESIZING';
const RESTING = 'RESTING';
const IDLING = 'IDLING';
const noop = () => null;
const css = {
collapse: 'ReactCollapse--collapse',
content: 'ReactCollapse--content'
};
function getContentHeightDefault(content, _wrapper) {
return content.clientHeight;
}
export class Collapse extends React.PureComponent {
static propTypes = {
isOpened: PropTypes.bool.isRequired,
springConfig: PropTypes.objectOf(PropTypes.number),
forceInitialAnimation: PropTypes.bool,
animateContentChanges: PropTypes.bool,
hasNestedCollapse: PropTypes.bool,
fixedHeight: PropTypes.number,
theme: PropTypes.objectOf(PropTypes.string),
style: PropTypes.object,
getContentHeight: PropTypes.func,
onRender: PropTypes.func,
onRest: PropTypes.func,
onMeasure: PropTypes.func,
children: PropTypes.node.isRequired
};
static defaultProps = {
springConfig: {},
forceInitialAnimation: false,
hasNestedCollapse: false,
animateContentChanges: true,
fixedHeight: -1,
style: {},
theme: css,
onRender: noop,
onRest: noop,
onMeasure: noop,
getContentHeight: getContentHeightDefault
};
constructor(props) {
super(props);
this.state = {
currentState: IDLING,
from: 0,
to: 0,
resizingFrom: 0
};
}
componentDidMount() {
const {isOpened, forceInitialAnimation, onRest} = this.props;
if (isOpened) {
const to = this.getTo();
if (forceInitialAnimation) {
const from = this.wrapper.clientHeight;
this.setState({currentState: RESIZING, from, to});
} else {
this.setState({currentState: IDLING, from: to, to});
}
}
onRest();
}
componentWillReceiveProps(nextProps) {
const {currentState, to, resizingFrom} = this.state;
const {isOpened, animateContentChanges} = this.props;
const openedChanged = nextProps.isOpened !== isOpened;
if (currentState === RESIZING && openedChanged) {
this.setState({from: this.currentHeight, to: resizingFrom, resizingFrom: to});
} else if (nextProps.hasNestedCollapse) {
// For nested collapses we do not need to change to waiting state
// and should keep `height:auto`
// Because children will be animated and height will not jump anyway
// See https://github.com/nkbt/react-collapse/issues/76 for more details
if (openedChanged) {
// Still go to WAITING state if own isOpened was changed
this.setState({currentState: WAITING});
}
} else if (currentState === IDLING) {
if (openedChanged || (isOpened && animateContentChanges)) {
this.setState({currentState: WAITING});
}
}
}
componentDidUpdate(prevProps, prevState) {
const {isOpened, onRest, onMeasure} = this.props;
const {currentState} = this.state;
if (currentState === IDLING) {
onRest();
return;
}
if (prevState.to !== this.state.to) {
onMeasure({height: this.state.to, width: this.content.clientWidth});
}
if (currentState !== RESIZING) {
const from = this.wrapper.clientHeight;
const to = isOpened ? this.getTo() : 0;
if (from !== to) {
this.setState({
currentState: RESIZING, from, to, resizingFrom: from
});
return;
}
if (currentState === RESTING || currentState === WAITING) {
this.setState({currentState: IDLING, from, to});
}
}
}
componentWillUnmount() {
cancelAnimationFrame(this.raf);
}
onContentRef = content => {
this.content = content;
};
onWrapperRef = wrapper => {
this.wrapper = wrapper;
};
onRest = () => {
this.raf = requestAnimationFrame(this.setResting);
};
setResting = () => {
this.currentHeight = undefined;
this.setState({currentState: RESTING, resizingFrom: undefined});
};
getTo = () => {
const {fixedHeight, getContentHeight} = this.props;
return (fixedHeight > -1) ? fixedHeight : getContentHeight(this.content, this.wrapper);
};
getWrapperStyle = height => {
if (this.state.currentState === IDLING && this.state.to) {
const {fixedHeight} = this.props;
if (fixedHeight > -1) {
return {overflow: 'hidden', height: fixedHeight};
}
return {height: 'auto'};
}
if (this.state.currentState === WAITING && !this.state.to) {
return {overflow: 'hidden', height: 0};
}
return {overflow: 'hidden', height: Math.max(0, height)};
};
getMotionProps = () => {
const {springConfig} = this.props;
return this.state.currentState === IDLING ? {
// When completely stable, instantly jump to the position
defaultStyle: {height: this.state.to},
style: {height: this.state.to}
} : {
// Otherwise, animate
defaultStyle: {height: this.state.from},
style: {height: spring(this.state.to, {precision: SPRING_PRECISION, ...springConfig})}
};
};
renderContent = ({height}) => { // eslint-disable-line
const {
isOpened: _isOpened,
springConfig: _springConfig,
forceInitialAnimation: _forceInitialAnimation,
animateContentChanges: _animateContentChanges,
hasNestedCollapse: _hasNestedCollapse,
fixedHeight: _fixedHeight,
theme,
style,
onRender,
onRest: _onRest,
onMeasure: _onMeasure,
getContentHeight: _getContentHeight,
children,
...props
} = this.props;
const {
from,
to,
currentState
} = this.state;
if (currentState === RESIZING) {
this.currentHeight = height;
}
// DANGEROUS, use with caution, never do setState with it
onRender({current: height, from, to});
return (
<div
ref={this.onWrapperRef}
className={theme.collapse}
style={{...this.getWrapperStyle(Math.max(0, height)), ...style}}
{...props}>
<div ref={this.onContentRef} className={theme.content}>{children}</div>
</div>
);
};
render() {
return (
<Motion {...this.getMotionProps()} onRest={this.onRest}>
{this.renderContent}
</Motion>
);
}
}