├── LICENSE ├── README.md ├── index.js └── package.json /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Josh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DEPRECATED 2 | 3 | Please use something like [react-virtualized](https://github.com/bvaughn/react-virtualized) for more efficient and supported features like this library provided 4 | 5 | 6 | 7 | # react-everscroll 8 | Performant large lists and infinite scrolling in React. 9 | 10 | **Large Lists** are problematic because the browser will render every dom element it is given, even if the element is hidden, consequently large lists in DOM will destroy browser performance. Everscroll solves this by rendering only a portion of your list at any given time, and recycling dom elements to render new list rows as you scroll. 11 | 12 | **Infinite Scrolling** becomes trivial, simply add new data to the index prop. Everscroll will invoke `onEndReached` prop so you know when to query for more data. 13 | 14 | ## Dependencies 15 | Assumes use of Babel or similar tool for ES6 to 5 transpiling and JSX compilation 16 | Component also currently depends on Immutable and lodash though these could be removed with a little effort (pull request if interested) 17 | 18 | ## Usage 19 | ```js 20 | function renderRow(ID){ 21 | return
{ID}
22 | } 23 | 24 | } // content will be placed at the end of your list 31 | frontCap={} // frontCap will be placed at the start of your list 32 | /> 33 | ``` 34 | 35 | ## Yet Another . . . 36 | There are a number of well made react infinite list components. While we have not tried them all, most have some limitations such as requiring fixed height rows, poor performance, or not having a "reverse" mode. 37 | 38 | Everscroll seeks to address all of these issues with a small and intuitive api. 39 | 40 | If you have a use case that is not covered by the current API, make a feature request! 41 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | //@TODO remove lodash (currently used for throttling) 3 | var _ = require('lodash'); 4 | var Immutable = require('immutable'); 5 | 6 | var Everscroll = React.createClass({ 7 | 8 | /** 9 | * Baseline State 10 | * @type {Object} 11 | */ 12 | _initialState: { 13 | listOffset: 0, 14 | keyStart: 0, 15 | frontHeight: 0, 16 | backHeight: 0, 17 | }, 18 | 19 | /** 20 | * Transient State 21 | */ 22 | _prependOffset: 0, 23 | _targetCursorOffset: 0, 24 | _initScroll: true, 25 | _scrollAfterUpdate: false, 26 | _seekFront: false, 27 | 28 | /** 29 | * reset container scroll to top or bottom depending on direction 30 | */ 31 | _initScrollTop: function () { 32 | this._initScroll = false; 33 | var containerEl = this.refs.root.getDOMNode(); 34 | if (this.props.reverse) { 35 | containerEl.scrollTop = containerEl.scrollHeight - containerEl.clientHeight; 36 | } else { 37 | containerEl.scrollTop = 0; 38 | } 39 | }, 40 | 41 | /** 42 | * determine ref name for cursor (middlemost rendered element on the screen) 43 | * @return {String} 44 | */ 45 | _calcCursorRef: function() { 46 | 47 | //get container 48 | var containerEl = this.refs.root.getDOMNode(); 49 | var threshold = containerEl.scrollTop + containerEl.clientHeight / 2; 50 | 51 | var clearThreshold = (ref) => { 52 | var node = this.refs[ref].getDOMNode(); 53 | //If reverse subtract offsetheight (so threshold relative to bottom of node instead of top) 54 | return node.offsetTop > threshold - (this.props.reverse ? node.offsetHeight : 0); 55 | } 56 | 57 | var refRange = this._getRefRenderRange(); 58 | 59 | var cursorRef = 60 | refRange 61 | .filter(ref => this.refs[ref]) 62 | .takeUntil(clearThreshold) 63 | .last() 64 | || 65 | refRange.first(); 66 | 67 | return cursorRef; 68 | 69 | }, 70 | 71 | /** 72 | * determine if new cursor is needed and set if necessary 73 | */ 74 | _setCursor: function() { 75 | 76 | var cursorRef = this._calcCursorRef(); 77 | 78 | if (this.state.cursorRef === cursorRef) { 79 | return; 80 | } 81 | 82 | this.setState({ 83 | cursorRef: cursorRef, 84 | }); 85 | }, 86 | 87 | /** 88 | * get renderable ref Range in incremental order 89 | * Front of list to Back of list 90 | * @return {Immutable.Seq} 91 | */ 92 | _getRefRange: function () { 93 | var rangeStart = this.state.listOffset; 94 | var rangeEnd = rangeStart + Math.min(this.props.renderCount, this.props.rowIndex.length); 95 | 96 | return Immutable.Range(rangeStart, rangeEnd) 97 | }, 98 | 99 | /** 100 | * get renderable ref Range in render order 101 | * Top of container to Bottom of container 102 | * @return {Immutable.Seq} 103 | */ 104 | _getRefRenderRange: function() { 105 | var refRange = this._getRefRange(); 106 | 107 | return this.props.reverse ? refRange.reverse() : refRange 108 | }, 109 | 110 | /** 111 | * recycled key Range for DOM reuse 112 | * @return {Immutable.List} 113 | */ 114 | //@TODO determine if keys provide any real performance benefit 115 | _getKeyList: function() { 116 | var keyStart = this.state.keyStart; 117 | var basicRange = Immutable.Range(0, this.props.renderCount); 118 | 119 | return basicRange.slice(keyStart).toList().concat(basicRange.slice(0, keyStart).toList()) 120 | }, 121 | 122 | /** 123 | * call renderRowHanlder for given rowIndex value (ID) 124 | */ 125 | _renderRow: function(ID, index){ 126 | return this.props.renderRowHandler(ID, index); 127 | }, 128 | 129 | _onEndReached: function() { 130 | setTimeout(this.props.onEndReached, 0) 131 | }, 132 | 133 | /** 134 | * determine if render range needs to shift and update state accordingly 135 | */ 136 | _handleScroll: function(){ 137 | 138 | var {reverse, renderCount, rowIndex} = this.props 139 | var {listOffset, keyStart} = this.state 140 | 141 | if (rowIndex.length < renderCount) return; 142 | 143 | var cursorRef = this._calcCursorRef() 144 | var impliedListOffset = (cursorRef - renderCount / 2) | 0 145 | 146 | var minOffset = 0 147 | var maxOffset = Math.max(0, rowIndex.length - renderCount) 148 | 149 | var adjustedOffset = Math.min(Math.max(impliedListOffset, minOffset), maxOffset); 150 | var offsetShift = adjustedOffset - listOffset 151 | var adjustedKeyStart = (keyStart + offsetShift) % renderCount 152 | 153 | if (adjustedOffset === maxOffset && offsetShift > 0) { 154 | this._onEndReached() 155 | } 156 | 157 | var renderRange = this._getRefRenderRange(); 158 | var refRange = this._getRefRange(); 159 | 160 | //@TODO come up with a better way seed top and bottom spacesrs and maintain consistency during the scroll 161 | // consider caching rendered row heights rather than approximating 162 | var averageHeight = this.refs.renderRows.getDOMNode().scrollHeight / renderRange.count() 163 | 164 | var shift = adjustedOffset - listOffset 165 | var backSpacerHeight = (rowIndex.length - refRange.last() - 1 - offsetShift) * averageHeight 166 | var frontSpacerHeight = (refRange.first() + offsetShift) * averageHeight 167 | 168 | this.setState({ 169 | cursorRef: cursorRef, 170 | listOffset: adjustedOffset, 171 | keyStart: adjustedKeyStart, 172 | frontHeight: frontSpacerHeight, 173 | backHeight: backSpacerHeight, 174 | }) 175 | }, 176 | 177 | propTypes: { 178 | rowIndex: React.PropTypes.array.isRequired, 179 | idKey: React.PropTypes.string, 180 | renderRowHandler: React.PropTypes.func, 181 | reverse: React.PropTypes.bool, 182 | renderCount: React.PropTypes.number, 183 | onEndReached: React.PropTypes.func, 184 | topCap: React.PropTypes.object, 185 | bottomCap: React.PropTypes.object 186 | }, 187 | 188 | getDefaultProps: function() { 189 | return { 190 | idKey: 'ID', 191 | renderRowHandler: function(ID){ 192 | return (
{ID}
) 193 | }, 194 | reverse: false, 195 | renderCount: 30, 196 | throttle: 16, 197 | loadBuffer: 30, 198 | onEndReached: function () {} 199 | } 200 | }, 201 | 202 | getInitialState: function() { 203 | return this._initialState; 204 | }, 205 | 206 | componentWillReceiveProps: function(nextProps){ 207 | var oldRowIndex = this.props.rowIndex 208 | var newRowIndex = nextProps.rowIndex 209 | 210 | // reset scroll and render if we go from now rows to rows 211 | if(oldRowIndex && oldRowIndex.length === 0 && newRowIndex && newRowIndex.length > 0){ 212 | this.setState(this._initialState); 213 | this._initScroll = true 214 | } 215 | 216 | // detect prepend and adjust listOffset if necessary 217 | else if (oldRowIndex[0] !== newRowIndex[0]) { 218 | var prependedMessageCount = 219 | Immutable 220 | .List(newRowIndex) 221 | .takeUntil(function (ref) { 222 | return oldRowIndex[0] === ref 223 | }) 224 | .count() 225 | 226 | //@TODO discuss, how best to seek front, and how to set threshold 227 | var containerEl = this.refs.root.getDOMNode() 228 | var seekFrontThreshold = 20 229 | if( (!this.props.reverse && containerEl.scrollTop < seekFrontThreshold) || (this.props.reverse && containerEl.scrollTop + containerEl.offsetHeight > containerEl.scrollHeight-seekFrontThreshold) ){ 230 | this._seekFront = true 231 | } 232 | 233 | //@TODO should detect if old message root is not in new row index and handle... 234 | this.setState({ 235 | listOffset: this.state.listOffset + (this._seekFront ? 0 : prependedMessageCount), 236 | cursorRef: this.state.cursorRef + prependedMessageCount, 237 | }) 238 | 239 | this._prependOffset = prependedMessageCount 240 | 241 | 242 | 243 | } 244 | 245 | else if (oldRowIndex[oldRowIndex.length - 1] !== newRowIndex[newRowIndex.length - 1]) { 246 | this.setState({listOffset: this.state.listOffset + 1}) 247 | } 248 | 249 | //@TODO more elegant handling? 250 | //this._scrollAfterUpdate = true 251 | 252 | if (this.props.renderCount >= newRowIndex.length) { 253 | process.nextTick(this.props.onEndReached) 254 | } 255 | 256 | }, 257 | 258 | shouldComponentUpdate: function(nextProps, nextState){ 259 | //@TODO: implement to prevent uncessary renders on prop changes 260 | return true 261 | }, 262 | 263 | componentDidMount: function() { 264 | this._handleScroll = _.throttle(this._handleScroll, this.props.throttle) 265 | this._initScrollTop() 266 | }, 267 | 268 | componentWillUpdate: function(nextProps, nextState){ 269 | if (nextState.cursorRef){ 270 | var containerEl = this.getDOMNode() 271 | var refOffset = this._prependOffset || 0 272 | this._prependOffset = 0; 273 | this._targetCursorOffset = this.refs[nextState.cursorRef - refOffset].getDOMNode().offsetTop - containerEl.scrollTop 274 | } 275 | }, 276 | 277 | componentDidUpdate: function(prevProps, prevState){ 278 | 279 | if (this.state.cursorRef){ 280 | var containerEl = this.getDOMNode(); 281 | var currentCursorOffset = this.refs[this.state.cursorRef].getDOMNode().offsetTop - containerEl.scrollTop 282 | var adjustment = currentCursorOffset - this._targetCursorOffset 283 | containerEl.scrollTop += adjustment 284 | } 285 | 286 | if (this._initScroll) { 287 | this._initScrollTop(); 288 | } 289 | 290 | //@TODO possible only call when requested instead of every update 291 | if(this._scrollAfterUpdate){ 292 | this._handleScroll() 293 | this._scrollAfterUpdate = false 294 | } 295 | 296 | if(this._seekFront){ 297 | containerEl.scrollTop = this.props.reverse ? containerEl.scrollHeight - containerEl.offsetHeight : 0 298 | this._seekFront = false 299 | } 300 | 301 | this._setCursor() 302 | }, 303 | 304 | render: function() { 305 | var {reverse, frontCap, backCap} = this.props 306 | var {backHeight, frontHeight} = this.state 307 | 308 | var refRange = this._getRefRange() 309 | 310 | var rows = this.props.rowIndex.slice(refRange.first(), refRange.last() + 1) 311 | var refs = this._getRefRenderRange().toArray() 312 | var keys = this._getKeyList().map(val => "everscroll-" + val).toArray() 313 | 314 | var [topSpacerHeight, bottomSpacerHeight] = reverse 315 | ? [backHeight, frontHeight] 316 | : [frontHeight, backHeight] 317 | 318 | if (reverse) { 319 | rows.reverse(); 320 | } 321 | 322 | var renderRows = rows.map((ID, index) =>{ 323 | return ( 324 |
325 | {this._renderRow(ID, index)} 326 |
327 | ) 328 | }) 329 | 330 | return ( 331 |
332 |
333 | {reverse ? backCap : frontCap} 334 |
335 |
336 |
337 | {renderRows} 338 |
339 |
340 |
341 | {!reverse ? backCap : frontCap} 342 |
343 |
344 | ) 345 | } 346 | 347 | }) 348 | 349 | module.exports = Everscroll 350 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-everscroll", 3 | "version": "0.0.4", 4 | "description": "Performant large lists and infinite scrolling in React.", 5 | "main": "index.js", 6 | "dependencies": { 7 | "immutable": "^3.6.2", 8 | "lodash": "^3.1.0" 9 | }, 10 | "peerDependencies": { 11 | "react": "0.12.x || >=0.13.0-beta.1" 12 | }, 13 | "devDependencies": {}, 14 | "scripts": { 15 | "test": "echo \"Error: no test specified\" && exit 1" 16 | }, 17 | "author": "gnoff ", 18 | "maintainers": [ 19 | { 20 | "name": "gnoff", 21 | "email": "josh@root-two.com" 22 | }, 23 | { 24 | "name": "rt2zz", 25 | "email": "zack@root-two.com" 26 | } 27 | ], 28 | "license": "MIT", 29 | "repository": { 30 | "type": "git", 31 | "url": "https://github.com/gnoff/react-everscroll.git" 32 | }, 33 | "keywords": [ 34 | "react", 35 | "reactjs", 36 | "infinite", 37 | "everscroll" 38 | ], 39 | "bugs": { 40 | "url": "https://github.com/gnoff/react-everscroll/issues" 41 | }, 42 | "homepage": "https://github.com/gnoff/react-everscroll" 43 | } 44 | --------------------------------------------------------------------------------