react-reveal
Version:
Really simple way to add reveal on scroll animation to your React app.
523 lines (489 loc) • 18.9 kB
JavaScript
/*
* RevealBase Component For react-reveal
*
* Copyright © Roman Nosov 2016, 2017
*
* This source code is licensed under the MIT license found in the
* LICENSE.txt file in the root directory of this source tree.
*/
import React from 'react';
import { string, object, number, bool, func, oneOfType, oneOf, shape, element } from 'prop-types';
import { namespace, ssr, disableSsr, globalHide, hideAll, cascade, collapseend, fadeOutEnabled, observerMode, raf } from './lib/globals';
//import Step from './lib/Step';
//import throttle from './lib/throttle';
const
inOut = shape({
make: func,
duration: number.isRequired,
delay: number.isRequired,
forever: bool,
count: number.isRequired,
style: object.isRequired,
reverse: bool,
}),
propTypes = {
//when: any,
//spy: any,
//margin: number,
collapse: bool,// oneOfType([bool, shape({ tag: string, props: object })]),
collapseEl: element,
cascade: bool,
wait: number,
// step: oneOfType([instanceOf(Step), string]),
force: bool,
disabled: bool,
appear: bool,
enter: bool,
exit: bool,
fraction: number,
//children: element.isRequired,
refProp: string,
innerRef: func,
onReveal: func,
//onEnter: func,
//onEntering: func,
//onEntered: func,
//onExit: func,
//onExiting: func,
//onExited: func,
unmountOnExit: bool,
mountOnEnter: bool,
inEffect: inOut.isRequired,
outEffect: oneOfType([ inOut, oneOf([ false ]) ]).isRequired,
ssrReveal: bool,
collapseOnly: bool,
ssrFadeout: bool,
},
defaultProps = {
fraction: 0.2,
//when: true,
refProp: 'ref',
//margin: 0,
},
//,
//contextTypes = {
// stepper: object,
//};
contextTypes = {
transitionGroup: object,
};
//childContextTypes = {
// transitionGroup: ()=>{},
//};
class RevealBase extends React.Component {
//getChildContext() {
// return { transitionGroup: null }; // allows for nested Transitions
//}
constructor(props, context) {
super(props, context);
this.isOn = props.when !== undefined ? !!props.when : true;
this.state = {
collapse: props.collapse //&& (props.appear || (context.transitionGroup&&!context.transitionGroup.isMounting))
? RevealBase.getInitialCollapseStyle(props)
: void 0,
style: {
opacity: (!this.isOn||props.ssrReveal) && props.outEffect ? 0 : void 0,
//visibility: props.when ? 'visible' : 'hidden',
},
};
this.savedChild = false;
//this.isListener = false;
this.isShown = false;
//this.ticking = false;
//this.observerMode = observerMode && !this.props.disableObserver;
if (!observerMode) {
this.revealHandler = this.makeHandler(this.reveal);
this.resizeHandler = this.makeHandler(this.resize);
}
else
this.handleObserve = this.handleObserve.bind(this);
//this.revealHandler = myThrottle(this.reveal.bind(this, false));
//this.revealHandler = rafThrottle(this.reveal.bind(this, false));
//this.revealHandler = rafThrottle(throttle(this.reveal.bind(this, false), 66));
//this.revealHandler = throttle(rafThrottle(this.reveal.bind(this, false)), 66);
//this.resizeHandler = throttle(this.resize.bind(this), 500);
this.saveRef = this.saveRef.bind(this);
}
saveRef(node) {
if (this.childRef)
this.childRef(node);
if (this.props.innerRef)
this.props.innerRef(node);
if (this.el !== node) { //probably redundant check
this.el = node && ('offsetHeight' in node) ? node : undefined;
this.observe(this.props, true);
}
}
invisible() {
if (!this || !this.el)
return;
this.savedChild = false;
if (!this.isShown) {
this.setState( { hasExited: true, collapse: this.props.collapse?{...this.state.collapse, visibility: 'hidden'}: null, style: { /*...this.state.style, visibility: 'hidden'*/opacity: 0}/*, collapsing: false */});
//if (this.props.onExited)
// this.props.onExited(this.el);
if (!observerMode && this.props.collapse)
window.document.dispatchEvent(collapseend);
}
}
animationEnd(func, cascade, { forever, count, delay, duration }) {
if (forever)
return;
//const el = this.finalEl || this.el;
const handler = () => {
if (!this || !this.el)
return;
this.animationEndTimeout = void 0;
//el.removeEventListener('animationend', handler);
func.call(this);
};
this.animationEndTimeout = window.setTimeout(handler, delay+(duration+(cascade?duration:0)*count));
//el.addEventListener('animationend', handler);
//this.animationEndEl = el;
//this.animationEndHandler = handler;
}
getDimensionValue() {
return this.el.offsetHeight + parseInt(window.getComputedStyle(this.el, null).getPropertyValue('margin-top'),10) + parseInt(window.getComputedStyle(this.el, null).getPropertyValue('margin-bottom'), 10);
}
// //const delta = this.props.duration>>2,
// // duration = delta,
// // delay = this.props.delay + (this.isOn ? 0 : this.props.duration - delta)
collapse(state, props, inOut) {
const total = inOut.duration + (props.cascade ? inOut.duration : 0),
height = this.isOn ? this.getDimensionValue() : 0;
let duration, delay;
if (props.collapseOnly) {
duration = inOut.duration/3;
delay = inOut.delay;
}
else {
let delta1 = total>>2, delta2 = delta1>>1;
duration = delta1; // + (props.when ? 0 : delta2),
delay = inOut.delay + (this.isOn ? 0 : total - delta1 - delta2);
state.style.animationDuration = `${total - delta1 + (this.isOn ? delta2 : -delta2)}ms`;
state.style.animationDelay = `${inOut.delay + (this.isOn ? delta1 - delta2 : 0)}ms`;
}
//const delta = total>>2,
// duration = props.when ? delta : total - delta,
// delay = inOut.delay + (props.when ? 0 : delta);
//duration = total;
//delay = inOut.delay;
state.collapse = {
height,
transition: `height ${duration}ms ease ${delay}ms`,// padding ${duration}ms ease ${delay}ms, border ${duration}ms ease ${delay}ms`,
overflow: props.collapseOnly ? 'hidden' : undefined,
//margin: 0, padding: 0, border: '1px solid transparent',
//boxSizing: 'border-box',
};
return state;
}
animate(props) {
if (!this || !this.el)
return;
this.unlisten();
if (this.isShown === this.isOn)
return;
this.isShown = this.isOn;
const leaving = !this.isOn && props.outEffect,
inOut = props[leaving ? 'outEffect' : 'inEffect'];
//collapse = 'collapse' in props;
let animationName = (('style' in inOut) && inOut.style.animationName) || void 0;
let state;
if (!props.collapseOnly)
{
if ((props.outEffect||this.isOn) && inOut.make)
animationName = inOut.make;
//animationName = inOut.make(leaving, props);
//animationName = (!leaving && this.enterAnimation) || inOut.make(leaving, props);
state = {/* status: leaving ? 'exiting':'entering',*/
hasAppeared: true,
hasExited: false,
collapse: undefined,
style: {
...inOut.style,
animationDuration: `${inOut.duration}ms`,
animationDelay: `${inOut.delay}ms`,
animationIterationCount: inOut.forever ? 'infinite' : inOut.count,
opacity: 1,
//visibility: 'visible',
animationName,
},
className: inOut.className
};
}
else
state = { hasAppeared: true, hasExited: false, style: {opacity: 1}};
this.setState( props.collapse ? this.collapse(state, props, inOut) : state );
if (leaving) {
this.savedChild = React.cloneElement(this.getChild());
this.animationEnd( this.invisible, props.cascade, inOut);
}
else
this.savedChild = false;
//if (collapse)
// this.animationEnd( () => this.setState({ collapse: void 0 }), props.cascade, inOut);
this.onReveal(props);
}
onReveal(props) {
if (props.onReveal && this.isOn) {
if (this.onRevealTimeout)
this.onRevealTimeout = window.clearTimeout(this.onRevealTimeout);
props.wait ? this.onRevealTimeout = window.setTimeout(props.onReveal, props.wait) : props.onReveal();
//props.wait ? this.onRevealTimeout = window.setTimeout( this.isOn ? (() => props.onReveal(true)):(() => props.onReveal(false)), props.wait) : props.onReveal( this.isOn );
}
}
componentWillUnmount() {
this.unlisten();
ssr && disableSsr();
}
handleObserve( [entry], observer ) {
if (entry.intersectionRatio>0) {
observer.disconnect();
this.observer = null;
this.reveal(this.props, true);
}
}
observe(props, update = false) {
if (!this.el) return;
if (observerMode) {
if (this.observer) {
if (update)
this.observer.disconnect();
else return;
}
else if (update) return;
this.observer = new IntersectionObserver(this.handleObserve, {threshold: props.fraction} );
this.observer.observe(this.el);
}
}
reveal(props, inView = false) {
if (!globalHide)
hideAll();
if (!this||!this.el) return;
if (!props)
props = this.props;
if (ssr)
disableSsr();
if ( this.isOn && this.isShown && props.spy !== undefined ){
this.isShown = false;
this.setState({ style: {} });
window.setTimeout( () => this.reveal(props), 200 );
}
else if ( inView || this.inViewport(props) || props.force )
this.animate(props);
else
observerMode?this.observe(props):this.listen();
}
componentDidMount() {
if (!this.el || this.props.disabled)
return;
if (!this.props.collapseOnly) {
if ('make' in this.props.inEffect)
this.props.inEffect.make(false, this.props);
if (this.props.when !== undefined && this.props.outEffect && 'make' in this.props.outEffect)
this.props.outEffect.make(true, this.props);
}
const parentGroup = this.context.transitionGroup;
const appear = parentGroup && !parentGroup.isMounting ? !('enter' in this.props && this.props.enter === false) : this.props.appear;
if (this.isOn && (((this.props.when !== undefined || this.props.spy !== undefined) && !appear)
|| (ssr && !fadeOutEnabled && !this.props.ssrFadeout && this.props.outEffect && !this.props.ssrReveal && (RevealBase.getTop(this.el) < window.pageYOffset + window.innerHeight)))
) {
this.isShown = true;
this.setState({
hasAppeared: true,
collapse: this.props.collapse ? { height: this.getDimensionValue() } : this.state.collapse,
style: { opacity: 1,}
});
this.onReveal(this.props);
return;
}
if ( ssr && ( fadeOutEnabled || this.props.ssrFadeout )&& this.props.outEffect && (RevealBase.getTop(this.el) < window.pageYOffset + window.innerHeight)) {
this.setState({ style: { opacity: 0, transition: 'opacity 1000ms 1000ms' } });
window.setTimeout( () => this.reveal(this.props, true), 2000);
return;
}
if(this.isOn)
this.props.force ? this.animate(this.props) : this.reveal(this.props);
// return this.animate(this.props);
//
// this.reveal(this.props);
}
cascade(children) {
let newChildren;
if (typeof children === 'string') {
newChildren = children.split("").map( (ch, index) => <span key={index} style={{display: 'inline-block', whiteSpace:'pre'}}>{ch}</span> );
//reverse = this.props.reverse;
}
else
newChildren = React.Children.toArray(children);
//if (newChildren.length === 1)
// return newChildren;
let { duration, reverse } = this.props[this.isOn || !this.props.outEffect ?'inEffect':'outEffect'],
count = newChildren.length,
total = duration*2;
//reverse = false;
if (this.props.collapse) {
total = parseInt(this.state.style.animationDuration, 10);
duration = total/2;
}
let i = reverse ? count : 0;
//let i = 0;
newChildren = newChildren.map( child =>
typeof child === 'object' && child //&& 'type' in child && typeof child.type === 'string'
? React.cloneElement(child,{
style: {
...child.props.style,
...this.state.style,
animationDuration: Math.round(cascade( reverse ? i-- : i++ /*i++*/,0 , count, duration, total)) + 'ms',
},
//ref: i === count? (el => this.finalEl = el) : void 0,
})
: child );
return newChildren;
}
static getInitialCollapseStyle(props) {
return {
height: 0,
visibility: props.when ? void 0 : 'hidden',
};
}
componentWillReceiveProps (props) {
if (props.when !== undefined)
this.isOn = !!props.when;
if (props.fraction !== this.props.fraction)
this.observe(props, true);
if (!this.isOn && props.onExited && ('exit' in props) && props.exit === false ) {
props.onExited();
return;
}
if (props.disabled)
return;
if (props.collapse && !this.props.collapse) {
this.setState({ style: { }, collapse: RevealBase.getInitialCollapseStyle(props)});
this.isShown = false;
}
if ( (props.when !== this.props.when) || (props.spy !== this.props.spy))
this.reveal(props);
if (this.onRevealTimeout&& !this.isOn)
this.onRevealTimeout = window.clearTimeout(this.onRevealTimeout);
}
getChild() {
if (this.savedChild && !this.props.disabled)
return this.savedChild;
if (typeof this.props.children === 'object') {
const child = React.Children.only(this.props.children);
return (('type' in child) && typeof child.type === 'string') || this.props.refProp !== 'ref'
? child
: <div>{child}</div>;
}
else
return <div>{this.props.children}</div>;
}
render() {
let mount;
if (!this.state.hasAppeared)
mount = !this.props.mountOnEnter || this.isOn;
else
mount = !this.props.unmountOnExit || !this.state.hasExited || this.isOn;
const child = this.getChild();
//if (this.props.disabled)
// return child;
if (typeof child.ref === 'function')
this.childRef = child.ref;
let
newChildren = false,
{ style, className, children } = child.props;
let
newClass = this.props.disabled ? className : `${ this.props.outEffect ? namespace : '' }${ this.state.className ? ' ' + this.state.className : '' }${ className ? ' ' + className : '' }`||void 0,
newStyle;
if (typeof this.state.style.animationName === 'function') // todo: needs refactotoring
this.state.style.animationName = this.state.style.animationName(!this.isOn ,this.props);
if (this.props.cascade && !this.props.disabled && children && this.state.style.animationName) {
newChildren = this.cascade(children);
newStyle = { ...style, opacity: 1 };
}
else
newStyle = this.props.disabled ? style : { ...style, ...this.state.style };
const props = { ...this.props.props, className: newClass, style: newStyle, [this.props.refProp]: this.saveRef };
//if (this.props.collapse && !this.props.disabled)
// props.key = 1;
const el = React.cloneElement(child, props, mount ? newChildren||children : undefined);
if ( this.props.collapse !== undefined )
return this.props.collapseEl
? React.cloneElement(this.props.collapseEl, { style: {...this.props.collapseEl.style, ...(this.props.disabled ? undefined : this.state.collapse)}, children: (el) })
: <div style={ this.props.disabled ? undefined : this.state.collapse } children={el} />;
//return <div {...this.props.collapse} style={ this.props.disabled ? undefined : this.state.collapse } children={el} />;
return el;
}
makeHandler(handler) {
const update = () => {
handler.call(this, this.props);
this.ticking = false;
};
return () => {
if (!this.ticking) {
raf(update);
this.ticking = true;
}
};
}
static getTop(el) {
while (el.offsetTop === void 0)
el = el.parentNode;
let top = el.offsetTop;
for (;el.offsetParent; top += el.offsetTop)
el = el.offsetParent;
return top;
}
inViewport(props) {
if (!this.el || window.document.hidden) return false;
const h = this.el.offsetHeight,
delta = window.pageYOffset/* - props.margin */- RevealBase.getTop(this.el),
tail = Math.min(h, window.innerHeight) * ( globalHide ? props.fraction : 0 );
return ( delta > tail - window.innerHeight ) && ( delta < h - tail );
}
resize(props) {
if (!this||!this.el||!this.isOn)
return;
if ( this.inViewport(props) ) {
this.unlisten();
this.isShown = this.isOn;
this.setState({ hasExited: !this.isOn, hasAppeared: true, collapse: undefined, style: { opacity: this.isOn || !props.outEffect ? 1 : 0 } });
this.onReveal(props);
//if (this.props.onReveal && this.isOn)
// this.props.wait ? this.onRevealTimeout = window.setTimeout(this.props.onReveal, this.props.wait) : this.props.onReveal();
}
}
listen() {
if (!observerMode && !this.isListener) {
this.isListener = true;
window.addEventListener('scroll', this.revealHandler, { passive: true });
window.addEventListener('orientationchange', this.revealHandler, { passive: true });
window.document.addEventListener('visibilitychange', this.revealHandler, { passive: true });
window.document.addEventListener('collapseend', this.revealHandler, { passive: true });
window.addEventListener('resize', this.resizeHandler, { passive: true });
}
}
unlisten() {
if (!observerMode && this.isListener) {
window.removeEventListener('scroll', this.revealHandler, { passive: true });
window.removeEventListener('orientationchange', this.revealHandler, { passive: true });
window.document.removeEventListener('visibilitychange', this.revealHandler, { passive: true });
window.document.removeEventListener('collapseend', this.revealHandler, { passive: true });
window.removeEventListener('resize', this.resizeHandler, { passive: true });
this.isListener = false;
}
if(this.onRevealTimeout)
this.onRevealTimeout = window.clearTimeout(this.onRevealTimeout);
if (this.animationEndTimeout)
this.animationEndTimeout = window.clearTimeout(this.animationEndTimeout);
//if (this.animationEndHandler)
// this.animationEndEl.removeEventListener('animationend', this.animationEndHandler);
}
}
RevealBase.propTypes = propTypes;
RevealBase.defaultProps = defaultProps;
RevealBase.contextTypes = contextTypes;
RevealBase.displayName = 'RevealBase';
//RevealBase.childContextTypes = childContextTypes;
export default RevealBase;