Created
September 29, 2015 13:36
-
-
Save chauthai/ecfb28e99065d91cb1fd to your computer and use it in GitHub Desktop.
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 _ from 'lodash'; | |
import React from 'react'; | |
import clamp from './clamp.js'; | |
import dimensions from './dimensions.js'; | |
class Scroller extends React.Component { | |
static propTypes = { | |
onScroll: React.PropTypes.func, | |
onNearTop: React.PropTypes.func, | |
target: React.PropTypes.object, | |
edgeSpace: React.PropTypes.object, | |
onScrollbarSize: React.PropTypes.number, | |
className: React.PropTypes.string, | |
children: React.PropTypes.node, | |
} | |
componentWillMount() { | |
this._onScroll = _.throttle(this.onScroll, 100); | |
this._checkScroll = _.throttle(this.checkScroll, 150); | |
this._targetInView = false; | |
this._lastViewHeight = 0; | |
this._lastScrollHeight = 0; | |
this._lastScrollTop = 0; | |
this._anchor = null; | |
this._anchorPos = null; | |
this._waitingForUpdate = false; | |
this._lastTouch = 0; | |
this._animationFrames = {}; | |
} | |
componentDidMount() { | |
this.scroll({forceTargetInView: true}); | |
this.checkScrollbar(); | |
} | |
componentWillUnmount() { | |
this._onScroll.cancel(); | |
this._checkScroll.cancel(); | |
} | |
onScroll() { | |
this._chromeRAFHack('onScroll', () => { | |
this._checkScroll(); | |
this.updateAnchorPos(); | |
if (this.props.onScroll) { | |
this.props.onScroll(this._isTouching()); | |
} | |
}); | |
} | |
onFocusCapture() { | |
// browser bugs and other difficult to account for shenanigans can cause | |
// unwanted scrolls when inputs get focus. :( FIGHT BACK! | |
// see https://code.google.com/p/chromium/issues/detail?id=437025 | |
setImmediate(() => { | |
this.scroll({ignoreScrollDelta: true}); | |
}); | |
} | |
onUpdate() { | |
this.scroll(); | |
this.checkScrollbar(); | |
} | |
onTouchStart() { | |
this._lastTouch = true; | |
// prevent overscroll from bleeding out in Mobile Safari | |
if (!Heim.isiOS) { | |
return; | |
} | |
// http://stackoverflow.com/a/14130056 | |
const node = this.getDOMNode(); | |
if (node.scrollTop === 0) { | |
node.scrollTop = 1; | |
} else if (node.scrollHeight === node.scrollTop + node.offsetHeight) { | |
node.scrollTop -= 1; | |
} | |
} | |
onTouchEnd() { | |
this._lastTouch = new Date(); | |
} | |
getPosition() { | |
const node = this.getDOMNode(); | |
if (!node.scrollHeight) { | |
return false; | |
} | |
const frac = node.scrollTop / (node.scrollHeight - node.clientHeight); | |
return frac ? Math.round(frac * 100) / 100 : 1; | |
} | |
_isTouching() { | |
return this._lastTouch === true || new Date() - this._lastTouch < 100; | |
} | |
checkScroll() { | |
if (this._waitingForUpdate) { | |
return; | |
} | |
const node = this.getDOMNode(); | |
if (node.scrollHeight === 0) { | |
return; | |
} | |
if (this.props.onNearTop && node.scrollTop < node.scrollHeight / 8) { | |
// since RAF doesn't execute while the page is hidden, scrolling in | |
// infinite scroll won't occur in Chrome if users are on another tab. | |
// this was causing an infinite loop: the log would continuously be | |
// fetched since the scrollTop remained at 0. | |
this._waitingForUpdate = true; | |
this._chromeRAFHack('checkScroll', this.props.onNearTop); | |
} | |
} | |
scroll(options = {}) { | |
// Scroll so our point of interest (target or anchor) is in the right place. | |
// | |
// Desired behavior: | |
// | |
// If options.forceTargetInView is set, ensure that the target is onscreen. | |
// If it is not, move it within edgeSpace of the top or bottom. | |
// | |
// If the target was previously in view, we want to ensure it still is. If | |
// we're at the bottom of the page, new content should be able to push the | |
// target up to edgeSpace. If we're jumping several rows, we want to make | |
// sure we end up within edgeSpace. Otherwise, movements that would take us | |
// past edgeSpace should scroll to keep the target within edgeSpace. | |
// | |
// If the target was not previously in view, maintain the position of the | |
// anchor element. | |
// | |
// Note: mobile Webkit does this funny thing where getting/setting | |
// scrollTop doesn't happen promptly during inertial scrolling. It turns | |
// out that setting scrollTop inside a requestAnimationFrame callback | |
// circumvents this issue. | |
this._chromeRAFHack('scroll', () => { | |
const node = this.getDOMNode(); | |
const nodeBox = dimensions(node); | |
const viewTop = nodeBox.top; | |
const viewHeight = nodeBox.height; | |
const scrollHeight = node.scrollHeight; | |
const target = node.querySelector(this.props.target); | |
const canScroll = viewHeight < scrollHeight; | |
const edgeSpace = Math.min(this.props.edgeSpace, viewHeight / 2); | |
let posRef; | |
let oldPos; | |
if (target && (options.forceTargetInView || this._targetInView)) { | |
const viewShrunk = viewHeight < this._lastViewHeight; | |
const hasGrown = scrollHeight > this._lastScrollHeight; | |
const fromBottom = scrollHeight - (node.scrollTop + viewHeight); | |
const canScrollBottom = canScroll && fromBottom <= edgeSpace; | |
const targetBox = dimensions(target); | |
const targetPos = targetBox.bottom; | |
const clampedPos = clamp(viewTop + edgeSpace - targetBox.height, targetPos, viewTop + viewHeight - edgeSpace); | |
const movingTowardsEdge = Math.sign(targetPos - this._anchorPos) !== Math.sign(clampedPos - targetPos); | |
const pastEdge = clampedPos !== targetPos; | |
const movingPastEdge = movingTowardsEdge && pastEdge; | |
const jumping = Math.abs(targetPos - this._anchorPos) > 3 * target.offsetHeight; | |
const shouldHoldPos = hasGrown || (movingPastEdge && !jumping); | |
const shouldScrollBottom = hasGrown && canScrollBottom || viewShrunk; | |
posRef = target; | |
if (this._targetInView && shouldHoldPos && !shouldScrollBottom) { | |
oldPos = this._anchorPos; | |
} else { | |
if (options.forceTargetInView && !this._targetInView || shouldScrollBottom || jumping) { | |
oldPos = clampedPos; | |
} | |
} | |
} else if (this._anchor) { | |
// Otherwise, try to keep the anchor element in the same place it was when | |
// we last saw it via updateAnchorPos. | |
posRef = this._anchor; | |
oldPos = this._anchorPos; | |
} | |
if (posRef) { | |
const delta = dimensions(posRef, 'bottom') - oldPos; | |
if (delta && canScroll) { | |
const scrollDelta = options.ignoreScrollDelta ? 0 : node.scrollTop - this._lastScrollTop; | |
this._lastScrollTop = node.scrollTop += delta + scrollDelta; | |
} | |
} | |
this.updateAnchorPos(); | |
this._checkScroll(); | |
}, options.immediate); | |
} | |
scrollToTarget(options = {}) { | |
options.forceTargetInView = true; | |
this.scroll(options); | |
} | |
updateAnchorPos() { | |
// Record the position of our point of reference. Either the target (if | |
// it's in view), or the centermost child element. | |
const node = this.getDOMNode(); | |
const nodeBox = dimensions(node); | |
const viewTop = nodeBox.top; | |
const viewHeight = nodeBox.height; | |
const target = node.querySelector(this.props.target); | |
let targetPos; | |
if (target) { | |
targetPos = dimensions(target, 'bottom'); | |
this._targetInView = targetPos >= viewTop - 5 + target.offsetHeight && targetPos <= viewTop + viewHeight + 5; | |
} else { | |
this._targetInView = false; | |
} | |
let anchor; | |
if (this._targetInView) { | |
this._anchor = target; | |
this._anchorPos = targetPos; | |
} else { | |
const box = dimensions(this.getDOMNode()); | |
const bodyBox = dimensions(uidocument.body); | |
const boxRight = Math.min(box.right, bodyBox.right); | |
anchor = uidocument.elementFromPoint(boxRight - 40, box.top + box.height / 2); | |
if (!anchor) { | |
console.warn('scroller: unable to find anchor'); // jshint ignore:line | |
} | |
this._anchor = anchor; | |
this._anchorPos = anchor && dimensions(anchor, 'bottom'); | |
} | |
this._lastScrollTop = node.scrollTop; | |
this._lastScrollHeight = node.scrollHeight; | |
this._lastViewHeight = viewHeight; | |
} | |
update() { | |
this._waitingForUpdate = false; | |
this.onUpdate(); | |
} | |
checkScrollbar() { | |
const node = this.getDOMNode(); | |
if (this.props.onScrollbarSize) { | |
const scrollbarWidth = node.offsetWidth - node.clientWidth; | |
if (scrollbarWidth !== this.scrollbarWidth) { | |
this.scrollbarWidth = scrollbarWidth; | |
this.props.onScrollbarSize(scrollbarWidth); | |
} | |
} | |
} | |
_chromeRAFHack(id, callback, immediate) { | |
if (!immediate && Heim.isChrome && Heim.isTouch) { | |
if (this._animationFrames[id]) { | |
return; | |
} | |
this._animationFrames[id] = uiwindow.requestAnimationFrame(() => { | |
this._animationFrames[id] = null; | |
callback(); | |
}); | |
} else { | |
callback(); | |
} | |
} | |
render() { | |
return ( | |
<div onScroll={this._onScroll} onFocusCapture={this.onFocusCapture} onTouchStart={this.onTouchStart} onTouchEnd={this.onTouchEnd} className={this.props.className}> | |
{this.props.children} | |
</div> | |
); | |
} | |
} | |
export default Scroller; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment