Created
May 18, 2018 20:47
-
-
Save janpaul123/1c63660d422f02ad65cc4f1092fc787a to your computer and use it in GitHub Desktop.
Copyright: Remix Software; License: MIT
This file contains hidden or 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
/* eslint-disable react/prop-types */ | |
import React from 'react'; | |
import ReactDOM from 'react-dom'; | |
import ReactTestUtils from 'react-dom/test-utils'; | |
import FastScrollComponent from './FastScrollComponent'; | |
describe('<FastScrollComponent>', function() { | |
const setupComponent = ({ cacheWhenNotVisible = false, height = 100 }) => { | |
// eslint-disable-line react/prop-types | |
const rowHeight = 25; | |
const rowWidth = 1000; | |
const rowCount = 100; | |
this.layers = new Array(rowCount); | |
return ( | |
<div | |
style={{ | |
width: rowWidth, | |
height: height, | |
position: 'relative', | |
}} | |
> | |
<FastScrollComponent.Container | |
width={rowWidth} | |
height={rowCount * rowHeight} | |
overscanPx={0} | |
snapPx={1} | |
ref={(el) => { | |
this.container = el; | |
}} | |
> | |
{new Array(rowCount).fill(0).map((_, x) => ( | |
<FastScrollComponent.Layer | |
key={x} | |
left={0} | |
top={rowHeight * x} | |
width={rowWidth} | |
height={rowHeight} | |
ref={(el) => { | |
this.layers[x] = el; | |
}} | |
cacheWhenNotVisible={cacheWhenNotVisible} | |
> | |
{() => <div style={{ height: rowHeight }}>{x}</div>} | |
</FastScrollComponent.Layer> | |
))} | |
</FastScrollComponent.Container> | |
</div> | |
); | |
}; | |
describe('cacheWhenNotVisible is false', () => { | |
beforeEach(() => { | |
this.component = window.renderComponent(setupComponent({})); | |
}); | |
it('displays the top four elements and hides the rest', () => { | |
expect(this.layers[3].state.rendered).toBeTruthy(); | |
expect(this.layers[4].state.rendered).toBeFalsy(); | |
}); | |
it('un-renders the top elements when scrolled', () => { | |
this.container._root.scrollTop = 100; // scroll 4th element out | |
ReactTestUtils.Simulate.scroll(this.container._root); | |
jasmine.clock().tick(100); | |
expect(this.layers[3].state.rendered).toBeFalsy(); | |
expect(this.layers[4].state.rendered).toBeTruthy(); | |
}); | |
it('renders additional elements when height is changed', () => { | |
window.renderComponentAgain(this.component, setupComponent({ height: 200 })); | |
jasmine.clock().tick(100); // trigger throttled update | |
expect(this.layers[0].state.rendered).toBeTruthy(); | |
expect(this.layers[7].state.rendered).toBeTruthy(); | |
expect(this.layers[8].state.rendered).toBeFalsy(); | |
}); | |
}); | |
describe('cacheWhenNotVisible is true', () => { | |
beforeEach(() => { | |
this.component = window.renderComponent( | |
setupComponent({ | |
cacheWhenNotVisible: true, | |
}), | |
); | |
}); | |
it('displays the top four elements and hides the rest', () => { | |
expect(this.layers[3].state.rendered).toBeTruthy(); | |
expect(ReactDOM.findDOMNode(this.layers[3]).style.display).toEqual(''); | |
expect(this.layers[4].state.rendered).toBeFalsy(); | |
}); | |
it('hides the top elements when scrolled', () => { | |
this.container._root.scrollTop = 100; // scroll 4th element out | |
ReactTestUtils.Simulate.scroll(this.container._root); | |
jasmine.clock().tick(100); | |
expect(this.layers[3].state.rendered).toBeTruthy(); | |
expect(ReactDOM.findDOMNode(this.layers[3]).style.display).toEqual('none'); | |
expect(this.layers[4].state.rendered).toBeTruthy(); | |
expect(ReactDOM.findDOMNode(this.layers[4]).style.display).toEqual(''); | |
}); | |
}); | |
}); |
This file contains hidden or 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 PropTypes from 'prop-types'; | |
import React from 'react'; | |
import shallowEqual from 'shallowequal'; | |
import throttle from 'lodash/throttle'; | |
const contextTypes = { | |
dimensions: PropTypes.object, | |
updateFuncs: PropTypes.array, | |
}; | |
const FastScrollComponent = { | |
Container: class extends React.Component { | |
static propTypes = { | |
children: PropTypes.node, | |
width: PropTypes.number.isRequired, | |
height: PropTypes.number.isRequired, | |
// These are advanced options for if you want to tweak the defaults. | |
// Number of pixels of "overscan" around the visible area. More reduces | |
// flickering when scrolling but is slower. | |
overscanPx: PropTypes.number, | |
// How long to wait after the last scroll event to restore pointer events | |
// again. Less means a better responding interface but is slower. | |
resetPointerEventsMs: PropTypes.number, | |
// How often we can rerender during scrolling. Less reduces flickering | |
// when scrolling but is slower, but too much can also be slower because | |
// more elements have to be rerendered. | |
scrollRefreshThrottleMs: PropTypes.number, | |
// The grid size that we snap to, to reduce the number of times we have to | |
// rerender. For example when this is 25, and the scrollTop is 30, we | |
// pretend that the scroll top is actually 25. Has to be less than | |
// overscanPx. More is faster because we rerender less often, but too much | |
// can be slower because more elements have to be rerendered. | |
snapPx: PropTypes.number, | |
}; | |
static childContextTypes = contextTypes; | |
static defaultProps = { | |
overscanPx: 100, | |
snapPx: 25, | |
scrollRefreshThrottleMs: 50, | |
resetPointerEventsMs: 200, | |
}; | |
getChildContext() { | |
return { dimensions: this._dimensions, updateFuncs: this._updateFuncs }; | |
} | |
componentWillMount() { | |
this._dimensions = { left: 0, top: 0, width: 0, height: 0 }; | |
this._updateFuncs = []; | |
this._throttledUpdateVisibleChildren = throttle( | |
this._updateVisibleChildren, | |
this.props.scrollRefreshThrottleMs, | |
); | |
} | |
componentDidMount() { | |
window.addEventListener('resize', this._throttledUpdateVisibleChildren); | |
this._throttledUpdateVisibleChildren(); | |
} | |
componentWillReceiveProps(nextProps) { | |
if (__DEV__) { | |
if (this.props.overscanPx !== nextProps.overscanPx) { | |
throw new Error('Changing <FastScrollComponent.Container overscanPx> is not supported'); | |
} | |
if (this.props.resetPointerEventsMs !== nextProps.resetPointerEventsMs) { | |
throw new Error( | |
'Changing <FastScrollComponent.Container resetPointerEventsMs> is not supported', | |
); | |
} | |
if (this.props.scrollRefreshThrottleMs !== nextProps.scrollRefreshThrottleMs) { | |
throw new Error( | |
'Changing <FastScrollComponent.Container scrollRefreshThrottleMs> is not supported', | |
); | |
} | |
if (this.props.snapPx !== nextProps.snapPx) { | |
throw new Error('Changing <FastScrollComponent.Container snapPx> is not supported'); | |
} | |
} | |
} | |
componentDidUpdate() { | |
this._throttledUpdateVisibleChildren(); | |
} | |
componentWillUnmount() { | |
window.removeEventListener('resize', this._throttledUpdateVisibleChildren); | |
} | |
scrollableElement = () => { | |
return this._root; | |
}; | |
_updateVisibleChildren = () => { | |
if (!this._root) return; | |
const { scrollLeft, scrollTop, clientWidth, clientHeight } = this._root; | |
const { overscanPx, snapPx, resetPointerEventsMs } = this.props; | |
// Snap to snapPx grid. | |
const snappedScrollLeft = Math.floor(scrollLeft / snapPx) * snapPx; | |
const snappedScrollTop = Math.floor(scrollTop / snapPx) * snapPx; | |
// Don't do anything if we're at the same position as last time. | |
if ( | |
snappedScrollLeft === this._lastSnappedScrollLeft && | |
snappedScrollTop === this._lastSnappedScrollTop && | |
clientWidth === this._lastClientWidth && | |
clientHeight === this._lastClientHeight | |
) { | |
return; | |
} | |
this._lastSnappedScrollLeft = snappedScrollLeft; | |
this._lastSnappedScrollTop = snappedScrollTop; | |
this._lastClientWidth = clientWidth; | |
this._lastClientHeight = clientHeight; | |
// Update visible dimensions, including overscan. | |
this._dimensions.left = snappedScrollLeft - overscanPx; | |
this._dimensions.top = snappedScrollTop - overscanPx; | |
this._dimensions.width = clientWidth + overscanPx * 2; | |
this._dimensions.height = clientHeight + overscanPx * 2; | |
// Let children know that visible dimensions have changed. | |
this._updateFuncs.forEach((updateFunc) => updateFunc()); | |
// Disable pointer events so underlying elements don't update while scrolling. | |
this._inner.style.pointerEvents = 'none'; | |
clearTimeout(this._pointerEventsTimeout); | |
this._pointerEventsTimeout = setTimeout(this._resetPointerEvents, resetPointerEventsMs); | |
}; | |
_resetPointerEvents = () => { | |
if (!this._root) return; | |
this._inner.style.pointerEvents = 'auto'; | |
}; | |
render() { | |
return ( | |
<div | |
style={{ | |
position: 'absolute', | |
left: 0, | |
top: 0, | |
width: '100%', | |
height: '100%', | |
overflow: 'scroll', // Necessary for easily correcting scrollbars using <ScrollbarPaddingComponent>. | |
willChange: 'transform', // More efficient painting (see https://redd.it/4a7a5u). | |
}} | |
onScroll={this._throttledUpdateVisibleChildren} | |
ref={(element) => (this._root = element)} | |
> | |
<div | |
style={{ | |
position: 'relative', | |
width: this.props.width, | |
height: this.props.height, | |
}} | |
ref={(element) => (this._inner = element)} | |
> | |
{this.props.children} | |
</div> | |
</div> | |
); | |
} | |
}, | |
Layer: class extends React.Component { | |
static propTypes = { | |
left: PropTypes.number.isRequired, | |
top: PropTypes.number.isRequired, | |
width: PropTypes.number.isRequired, | |
height: PropTypes.number.isRequired, | |
children: PropTypes.func, // Always a func, so we don't accidentally create unnecessary elements. | |
// TODO(JP): Should this be an option? Maybe always do this until children are changed? | |
cacheWhenNotVisible: PropTypes.bool, | |
}; | |
static contextTypes = contextTypes; | |
static childContextTypes = contextTypes; | |
state = { rendered: false }; | |
getChildContext() { | |
return { dimensions: this._dimensions, updateFuncs: this._updateFuncs }; | |
} | |
componentWillMount() { | |
this._dimensions = { left: 0, top: 0, width: 0, height: 0 }; | |
this._updateFuncs = []; | |
} | |
componentDidMount() { | |
this.context.updateFuncs.push(this._updateVisibility); | |
this._updateVisibility(); | |
if (__DEV__) this._assertPosition(); | |
} | |
componentDidUpdate(prevProps) { | |
// TODO(JP): Since this may trigger a state change, do part of it | |
// (the state change) in componentWillReceiveProps instead. | |
// Also add a shouldComponentUpdate so we don't fully rerender when | |
// children don't change (instead just update the <div> directly). | |
if (!shallowEqual(this.props, prevProps)) { | |
this._updateVisibility(); | |
} | |
if (__DEV__) this._assertPosition(); | |
} | |
componentWillUnmount() { | |
const index = this.context.updateFuncs.indexOf(this._updateVisibility); | |
if (index === -1) throw new Error('Entry not found in updateFuncs'); | |
this.context.updateFuncs.splice(index, 1); | |
} | |
_updateVisibility = () => { | |
const { left, top, width, height } = this.context.dimensions; | |
this._dimensions.left = left - this.props.left; | |
this._dimensions.top = top - this.props.top; | |
this._dimensions.width = width; | |
this._dimensions.height = height; | |
const visible = | |
this.props.left < left + width && | |
this.props.left + this.props.width > left && | |
this.props.top < top + height && | |
this.props.top + this.props.height > top; | |
if (visible) { | |
if (!this.state.rendered) { | |
this.setState({ rendered: true }); | |
} else { | |
if (this.props.cacheWhenNotVisible) { | |
this._element.style.display = 'block'; | |
} | |
this._updateFuncs.forEach((updateFunc) => updateFunc()); | |
} | |
} else if (this.state.rendered) { | |
if (this.props.cacheWhenNotVisible) { | |
this._element.style.display = 'none'; | |
} else { | |
this.setState({ rendered: false }); | |
} | |
} | |
}; | |
_assertPosition = () => { | |
if (this.state.rendered) { | |
if (!this._element.offsetLeft === this.props.left) { | |
throw new Error('offsetLeft does not match <FastScrollComponent.Layer left>'); | |
} | |
if (!this._element.offsetTop === this.props.top) { | |
throw new Error('offsetTop does not match <FastScrollComponent.Layer top>'); | |
} | |
} | |
}; | |
render() { | |
if (!this.state.rendered) return null; | |
return ( | |
<div | |
style={{ | |
position: 'absolute', | |
left: this.props.left, | |
top: this.props.top, | |
width: this.props.width, | |
height: this.props.height, | |
}} | |
ref={(element) => (this._element = element)} | |
> | |
{this.props.children()} | |
</div> | |
); | |
} | |
}, | |
}; | |
export default FastScrollComponent; |
This file contains hidden or 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 FastScrollComponent from './FastScrollComponent'; | |
import React from 'react'; | |
export default { | |
examples: [ | |
{ | |
title: '<FastScrollComponent>', | |
component: class extends React.Component { | |
render() { | |
return ( | |
<div style={{ position: 'relative', width: 600, height: 300 }}> | |
<FastScrollComponent.Container width={30 * 100} height={30 * 100}> | |
{new Array(100).fill(0).map((_, x) => ( | |
<FastScrollComponent.Layer | |
key={x} | |
left={x * 30} | |
top={0} | |
width={30} | |
height={30 * 100} | |
cacheWhenNotVisible | |
> | |
{() => | |
new Array(100).fill(0).map((__, y) => ( | |
<FastScrollComponent.Layer | |
key={y} | |
left={0} | |
top={y * 30} | |
width={30} | |
height={30} | |
cacheWhenNotVisible | |
> | |
{() => ( | |
<div | |
style={{ position: 'absolute', width: 30, height: 30, fontSize: 6 }} | |
> | |
{x}, {y} | |
</div> | |
)} | |
</FastScrollComponent.Layer> | |
)) | |
} | |
</FastScrollComponent.Layer> | |
))} | |
</FastScrollComponent.Container> | |
</div> | |
); | |
} | |
}, | |
}, | |
], | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment