Skip to content

Instantly share code, notes, and snippets.

@sompylasar
Last active March 18, 2017 11:16
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sompylasar/8b787c3fcfe0e14bba869a4e9a883135 to your computer and use it in GitHub Desktop.
Save sompylasar/8b787c3fcfe0e14bba869a4e9a883135 to your computer and use it in GitHub Desktop.
Viewport React component with animated scrolling and overlay handling.
:global(.react-html).htmlModeOverlayed {
:global(.react-root) {
position: fixed;
width: 100%;
transform: translate3d(0, 0, 0);
}
}
import React, { Component, PropTypes } from 'react';
import verge from 'verge';
import { Motion, spring } from 'react-motion';
import withStyles from 'isomorphic-style-loader/lib/withStyles';
import styles from './styles.scss';
function _getActualScrollX() {
return Math.max(0, verge.scrollX());
}
function _getActualScrollY() {
return Math.max(0, verge.scrollY());
}
function _setActualScroll(scrollX, scrollY) {
const scrollXActual = _getActualScrollX();
const scrollYActual = _getActualScrollY();
if (scrollX !== scrollXActual || scrollY !== scrollYActual) {
window.scrollTo(scrollX, scrollY);
}
}
function _getChangeDirection(curr, prev) {
let dir = (curr - prev);
dir = (dir > 0 ? 1 : dir);
dir = (dir < 0 ? -1 : dir);
return dir;
}
@withStyles(styles)
class Viewport extends Component {
constructor(props) {
super(props);
this._viewportWActual = verge.viewportW();
this._viewportHActual = verge.viewportH();
this._scrollXActual = _getActualScrollX();
this._scrollYActual = _getActualScrollY();
this._viewportWLastReading = this._viewportWActual;
this._viewportHLastReading = this._viewportHActual;
this._scrollXLastReading = this._scrollXActual;
this._scrollYLastReading = this._scrollYActual;
this._scrollXAnimationDirection = 0;
this._scrollYAnimationDirection = 0;
this._scrollXAnimationStart = this._scrollXActual;
this._scrollYAnimationStart = this._scrollYActual;
this._overlayOffsetX = null;
this._overlayOffsetY = null;
}
componentDidMount() {
if (!__CLIENT__) {
return;
}
this._overlayOffsetEl = document.querySelector('.react-root');
_setActualScroll(this.props.scrollX, this.props.scrollY);
this._handleWindowScrollResize();
window.addEventListener('load', this._handleWindowLoad);
window.addEventListener('resize', this._handleWindowScrollResize);
window.addEventListener('orientationchange', this._handleWindowScrollResize);
window.addEventListener('scroll', this._handleWindowScrollResize);
}
componentWillReceiveProps(nextProps) {
if (nextProps.scrollShouldAnimate) {
this._setScrollAnimationDirection(
_getChangeDirection(nextProps.scrollX, this._scrollXActual),
_getChangeDirection(nextProps.scrollY, this._scrollYActual),
);
this._scrollXAnimationStart = this._scrollXActual;
this._scrollYAnimationStart = this._scrollYActual;
}
else {
this._setScrollAnimationDirection(0, 0);
}
this._handleOverlays(nextProps);
}
componentWillUnmount() {
if (!__CLIENT__) {
return;
}
clearTimeout(this._windowLoadDelayedTimer);
cancelAnimationFrame(this._scrollAndSizeHandlerTimer);
clearTimeout(this._scrollAnimationEndedTimer);
window.removeEventListener('load', this._handleWindowLoad);
window.removeEventListener('resize', this._handleWindowScrollResize);
window.removeEventListener('orientationchange', this._handleWindowScrollResize);
window.removeEventListener('scroll', this._handleWindowScrollResize);
this._setScrollAnimationDirection(0, 0);
this._runWindowScrollResizeEffects();
this._overlayOffsetEl = null;
}
_setScrollAnimationDirection(scrollXAnimationDirection, scrollYAnimationDirection, isUserScroll) {
const wasAnimating = (
this._scrollXAnimationDirection !== 0 ||
this._scrollYAnimationDirection !== 0
);
this._scrollXAnimationDirection = scrollXAnimationDirection;
this._scrollYAnimationDirection = scrollYAnimationDirection;
if ( wasAnimating && scrollXAnimationDirection === 0 && scrollYAnimationDirection === 0 ) {
const {
onScrollAnimationEnded,
} = this.props;
onScrollAnimationEnded({
scrollX: this._scrollXActual,
scrollY: this._scrollYActual,
isUserScroll: isUserScroll,
});
}
}
_handleWindowLoad = () => {
this._runWindowScrollResizeEffects();
// WORKAROUND(@sompylasar): Add a delay to skip the browser internal scroll restoration behavior
// which cannot be neither cancelled nor determined reliably, but seems to happen after the window load event.
// @see http://stackoverflow.com/a/12045150
this._windowLoadDelayedTimer = setTimeout(this._handleWindowLoadDelayed, 10);
}
_handleWindowLoadDelayed = () => {
_setActualScroll(this.props.scrollX, this.props.scrollY);
}
_handleWindowScrollResize = () => {
// NOTE(@sompylasar): Read the viewport size and the scroll position immediately to have more accurate change tracking.
// WORKAROUND(@sompylasar): Read both the viewport size and the scroll position because they could have changed due to the mobile browser sliding bars (e.g. iOS Safari).
this._viewportWLastReading = verge.viewportW();
this._viewportHLastReading = verge.viewportH();
this._scrollXLastReading = _getActualScrollX();
this._scrollYLastReading = _getActualScrollY();
// The rest of the handling goes in the animation frame handler.
cancelAnimationFrame(this._scrollAndSizeHandlerTimer);
this._scrollAndSizeHandlerTimer = requestAnimationFrame(this._runWindowScrollResizeEffects);
}
_runWindowScrollResizeEffects = () => {
const {
viewportW,
viewportH,
onUserScroll,
onResize,
} = this.props;
const viewportWActual = this._viewportWActual = this._viewportWLastReading;
const viewportHActual = this._viewportHActual = this._viewportHLastReading;
const shouldReportViewportSizeChange = (
viewportWActual !== viewportW ||
viewportHActual !== viewportH
);
const scrollXActualPrev = this._scrollXActual;
const scrollYActualPrev = this._scrollYActual;
const scrollXActual = this._scrollXLastReading;
const scrollYActual = this._scrollYLastReading;
if ( scrollXActual !== this._scrollXActual || scrollYActual !== this._scrollYActual ) {
this._scrollXActual = scrollXActual;
this._scrollYActual = scrollYActual;
// Determine if this 'scroll' event is a user scroll, not the animation.
// If the user scrolls towards the animation, continue the animation.
// If the user scrolls against the animation, stop the animation.
let isUserScroll = true;
if (this._scrollXAnimationDirection !== 0 || this._scrollYAnimationDirection !== 0) {
const scrollXChangeDirection = _getChangeDirection(scrollXActual, scrollXActualPrev);
const scrollYChangeDirection = _getChangeDirection(scrollYActual, scrollYActualPrev);
isUserScroll = (
(scrollXChangeDirection !== 0 && scrollXChangeDirection !== this._scrollXAnimationDirection) ||
(scrollYChangeDirection !== 0 && scrollYChangeDirection !== this._scrollYAnimationDirection)
);
}
// If it's the user trying to scroll, stop the animaiton and invoke the callback.
// Do nothing if the scroll is considered an animation.
if ( isUserScroll ) {
this._setScrollAnimationDirection(0, 0, true);
onUserScroll({
scrollX: scrollXActual,
scrollY: scrollYActual,
});
}
}
if ( shouldReportViewportSizeChange ) {
onResize({
viewportW: viewportWActual,
viewportH: viewportHActual,
});
}
}
_handleOverlays(nextProps) {
const {
isOverlayActive,
} = nextProps;
if (isOverlayActive !== this.props.isOverlayActive) {
const htmlClassNamePrev = document.documentElement.className;
let htmlClassNameNext = (' ' + htmlClassNamePrev + ' ').replace(' ' + styles.htmlModeOverlayed + ' ', '').replace(/^\s+|\s+$/g, '');
if (isOverlayActive) {
htmlClassNameNext += ' ' + styles.htmlModeOverlayed;
}
document.documentElement.className = htmlClassNameNext;
if (isOverlayActive) {
if (this._overlayOffsetX !== nextProps.overlayOffsetX) {
this._overlayOffsetX = (nextProps.overlayOffsetX || 0);
this._overlayOffsetEl.style.left = (-this._overlayOffsetX) + 'px';
}
if (this._overlayOffsetY !== nextProps.overlayOffsetY) {
this._overlayOffsetY = (nextProps.overlayOffsetY || 0);
this._overlayOffsetEl.style.top = (-this._overlayOffsetY) + 'px';
}
}
else {
this._overlayOffsetEl.style.left = '';
this._overlayOffsetEl.style.top = '';
if (this._overlayOffsetX !== null && this._overlayOffsetY !== null) {
_setActualScroll(this._overlayOffsetX, this._overlayOffsetY);
}
this._overlayOffsetX = null;
this._overlayOffsetY = null;
}
}
}
_handleScrollAnimationEnded = () => {
if (this._scrollXAnimationDirection !== 0 || this._scrollYAnimationDirection !== 0) {
const {
scrollX,
scrollY,
} = this.props;
_setActualScroll(scrollX, scrollY);
this._setScrollAnimationDirection(0, 0);
}
}
render() {
if (!__CLIENT__) {
return null;
}
const {
scrollX,
scrollY,
scrollShouldAnimate,
} = this.props;
if (this._scrollXAnimationDirection === 0 && this._scrollYAnimationDirection === 0) {
if (scrollShouldAnimate) {
_setActualScroll(scrollX, scrollY);
}
return null;
}
return (
<Motion
defaultStyle={{
scrollX: this._scrollXAnimationStart,
scrollY: this._scrollYAnimationStart,
}}
style={{
scrollX: spring(scrollX, [ 120, 17 ]),
scrollY: spring(scrollY, [ 120, 17 ]),
}}
>
{(interpolatedStyle) => {
// Prevent overshoot, it causes tiny jump to the final value when the animation ends preliminary.
const scrollXLimited = (this._scrollXAnimationDirection < 0
? Math.max(scrollX, interpolatedStyle.scrollX)
: Math.min(scrollX, interpolatedStyle.scrollX)
);
const scrollYLimited = (this._scrollYAnimationDirection < 0
? Math.max(scrollY, interpolatedStyle.scrollY)
: Math.min(scrollY, interpolatedStyle.scrollY)
);
// Animation in progress.
_setActualScroll(scrollXLimited, scrollYLimited);
const hasAnimationEndedByThreshold = (
Math.abs(scrollXLimited - scrollX) < 0.01 &&
Math.abs(scrollYLimited - scrollY) < 0.01
);
// NOTE(@sompylasar): If the next frame doesn't fire, the animation has ended. Use `setTimeout` to guarantee execution; `requestAnimationFrame` does not guarantee execution.
clearTimeout(this._scrollAnimationEndedTimer);
this._scrollAnimationEndedTimer = setTimeout(this._handleScrollAnimationEnded, (hasAnimationEndedByThreshold ? 0 : 50));
return null;
}}
</Motion>
);
}
}
Viewport.displayName = 'Viewport';
Viewport.propTypes = {
viewportW: PropTypes.number.isRequired,
viewportH: PropTypes.number.isRequired,
onResize: PropTypes.func.isRequired,
scrollX: PropTypes.number.isRequired,
scrollY: PropTypes.number.isRequired,
scrollShouldAnimate: PropTypes.bool.isRequired,
viewportStateVersion: PropTypes.number.isRequired,
onUserScroll: PropTypes.func.isRequired,
onScrollAnimationEnded: PropTypes.func.isRequired,
isOverlayActive: PropTypes.bool.isRequired,
overlayOffsetX: PropTypes.number,
overlayOffsetY: PropTypes.number,
};
export default Viewport;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment