Last active
March 18, 2017 11:16
-
-
Save sompylasar/8b787c3fcfe0e14bba869a4e9a883135 to your computer and use it in GitHub Desktop.
Viewport React component with animated scrolling and overlay handling.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
:global(.react-html).htmlModeOverlayed { | |
:global(.react-root) { | |
position: fixed; | |
width: 100%; | |
transform: translate3d(0, 0, 0); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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