├── .gitignore ├── .npmignore ├── LICENSE ├── Readme.md ├── package-lock.json ├── package.json ├── rollup.config.js └── src ├── index.js └── utils.js /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # testing 5 | /coverage 6 | 7 | # production 8 | /dist -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | rollup.config.js 3 | Readme.md -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Aditya Kumawat 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 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | ## react-dynamic-virtual-scroll 2 | 3 | React component available to implement virtual-scroll at any page without worrying about the dynamic item height. 4 | 5 | You can play with the library over here: [Codesandbox](https://codesandbox.io/s/virtual-scroll-simple-list-27om6) 6 | 7 | ### Installation 8 | 9 | ```js 10 | npm install react-dynamic-virtual-scroll 11 | ``` 12 | 13 | No external dependencies so no need to worry about security and package size. 14 | 15 | ### Usage 16 | 17 | - Import component. 18 | 19 | ```jsx 20 | import VirtualScroll from "react-dynamic-virtual-scroll"; 21 | ``` 22 | 23 | - Add component as follows in your render method: 24 | 25 | ```jsx 26 | { 31 | return ( 32 |
33 |

List item: {rowIndex}

34 |
35 | ); 36 | }} 37 | /> 38 | ``` 39 | 40 | ### Props Table 41 | 42 | | name | type | required | default | description | 43 | | ------------- | ------------------------------- | -------- | ------- | ------------------------------------------------------------ | 44 | | minItemHeight | number | true | | Minimum item height to calculate the placeholder spacing. | 45 | | totalLength | number | true | | Total number of items to be rendered. | 46 | | renderItem | (rowIndex) => React.ReactNode | true | | Callback to render items for specified index values. **0-indexed** | 47 | | length | number | | 30 | Total number of items to be rendered in the dom. | 48 | | buffer | number | | 10 | Total number of items to be rendered in the dom before and after your required dom items. | 49 | 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-dynamic-virtual-scroll", 3 | "version": "1.4.0", 4 | "description": "React virtual scroll library", 5 | "main": "dist/rvs.js", 6 | "module": "dist/rvs-es.js", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "build": "rollup -c", 10 | "watch": "rollup -c -w" 11 | }, 12 | "devDependencies": { 13 | "@babel/core": "^7.14.2", 14 | "@babel/plugin-proposal-class-properties": "^7.8.3", 15 | "@babel/preset-env": "^7.9.0", 16 | "@babel/preset-react": "^7.9.1", 17 | "@rollup/plugin-babel": "^5.3.0", 18 | "@rollup/plugin-commonjs": "^17.1.0", 19 | "@rollup/plugin-node-resolve": "^11.2.0", 20 | "@rollup/plugin-replace": "^2.4.1", 21 | "react": "^16.13.1", 22 | "react-dom": "^16.13.1", 23 | "rollup": "^2.42.1", 24 | "rollup-plugin-filesize": "^9.1.1", 25 | "rollup-plugin-uglify-es": "^0.0.1" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/aditya-kumawat/react-dynamic-virtual-scroll.git" 30 | }, 31 | "keywords": [ 32 | "react", 33 | "virtual-scroll", 34 | "scroll", 35 | "lazy-loading" 36 | ], 37 | "author": "Aditya Kumawat", 38 | "license": "MIT", 39 | "bugs": { 40 | "url": "https://github.com/aditya-kumawat/react-dynamic-virtual-scroll/issues" 41 | }, 42 | "homepage": "https://github.com/aditya-kumawat/react-dynamic-virtual-scroll#readme" 43 | } 44 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import babel from '@rollup/plugin-babel'; 4 | import replace from '@rollup/plugin-replace'; 5 | import uglify from 'rollup-plugin-uglify-es'; 6 | import filesize from 'rollup-plugin-filesize'; 7 | 8 | const globals = { 9 | react: 'React' 10 | }; 11 | 12 | const extensions = [ 13 | '.js', '.jsx', 14 | ]; 15 | 16 | const formats = ['umd', 'es']; 17 | 18 | export default { 19 | input: './src/index.js', 20 | external: Object.keys(globals), 21 | plugins: [ 22 | resolve({ extensions, preferBuiltins: false }), 23 | commonjs({ include: 'node_modules' }), 24 | babel({ 25 | extensions, 26 | babelHelpers: 'bundled', 27 | include: ['src/**/*'], 28 | presets: [ 29 | '@babel/preset-env', 30 | '@babel/preset-react' 31 | ], 32 | plugins: [ 33 | '@babel/plugin-proposal-class-properties' 34 | ] 35 | }), 36 | replace({ 37 | preventAssignment: true, 38 | 'process.env.NODE_ENV': JSON.stringify('production') 39 | }), 40 | uglify(), 41 | filesize() 42 | ], 43 | output: [{ 44 | exports: 'named', 45 | globals, 46 | name: 'ReactVirtualScroll', 47 | file: './dist/rvs.js', 48 | format: 'umd' 49 | }, 50 | { 51 | name: 'ReactVirtualScroll', 52 | file: './dist/rvs-es.js', 53 | format: 'es' 54 | }] 55 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { isInView } from './utils'; 3 | 4 | class VirtualScroll extends React.Component { 5 | constructor(props) { 6 | super(props); 7 | 8 | this.state = { 9 | offset: props.offset, 10 | }; 11 | 12 | this.lastScrollTop = 0; 13 | this.avgRowHeight = props.minItemHeight; 14 | } 15 | 16 | static defaultProps = { 17 | buffer: 10, 18 | length: 30, 19 | offset: 0, 20 | } 21 | 22 | componentDidMount() { 23 | window.requestAnimationFrame(() => { 24 | if (this.listRef) { 25 | this.listRef.scrollTop = this.state.offset * this.avgRowHeight; 26 | } 27 | }); 28 | } 29 | 30 | componentDidUpdate(_prevProps, prevState) { 31 | if (prevState.offset > this.state.offset) { 32 | this.updateOffset(prevState); 33 | } 34 | } 35 | 36 | updateOffset(prevState) { 37 | const offsetDiff = prevState.offset - this.state.offset; 38 | if (this.listRef) { 39 | const el = this.listRef; 40 | const items = el.querySelectorAll(".VS-item"); 41 | 42 | let heightAdded = 0; 43 | let currOffset = prevState.offset; 44 | const start = Math.min(this.state.offset, this.props.buffer); 45 | const end = start + offsetDiff; 46 | for (let i = Math.min(items.length, end) - 1; i >= start; i--) { 47 | const inView = isInView(el, items[i]); 48 | if (inView) { 49 | currOffset--; 50 | const rowHeight = items[i].clientHeight; 51 | heightAdded += rowHeight; 52 | } else { 53 | break; 54 | } 55 | } 56 | 57 | if (items.length < end) { 58 | const diff = end - items.length; 59 | heightAdded += diff * this.props.minItemHeight; 60 | currOffset -= diff; 61 | } 62 | 63 | const newAvgRowHeight = 64 | currOffset === 0 65 | ? this.props.minItemHeight 66 | : (this.avgRowHeight * prevState.offset - heightAdded) / currOffset; 67 | 68 | this.setState({ 69 | offset: currOffset, 70 | }); 71 | this.avgRowHeight = Math.max(this.props.minItemHeight, newAvgRowHeight); 72 | } 73 | } 74 | 75 | onScrollHandler(event) { 76 | if (this.listRef) { 77 | const { 78 | totalLength, 79 | length, 80 | buffer 81 | } = this.props; 82 | 83 | const { 84 | offset, 85 | } = this.state; 86 | 87 | const { 88 | avgRowHeight 89 | } = this; 90 | 91 | const el = this.listRef; 92 | const { scrollTop } = el; 93 | const direction = Math.floor(scrollTop - this.lastScrollTop); 94 | 95 | if (direction === 0) return; 96 | 97 | const items = el.querySelectorAll(".VS-item"); 98 | let newOffset = offset; 99 | let newAvgRowHeight = avgRowHeight; 100 | const start = Math.min(offset, buffer); 101 | if (direction > 0) { 102 | if (offset < totalLength - length) { 103 | let heightAdded = 0; 104 | for (let i = start; i < items.length; i++) { 105 | const inView = isInView(el, items[i]); 106 | const rowHeight = items[i].clientHeight; 107 | if (!inView) { 108 | heightAdded += rowHeight; 109 | newOffset++; 110 | } else { 111 | break; 112 | } 113 | } 114 | if (heightAdded < direction) { 115 | const heightLeft = direction - heightAdded; 116 | const offsetToBeAdded = Math.floor(heightLeft / this.props.minItemHeight) 117 | newOffset += offsetToBeAdded; 118 | heightAdded += offsetToBeAdded * this.props.minItemHeight 119 | } 120 | newAvgRowHeight = newOffset > 0 121 | ? ((offset * avgRowHeight) + (heightAdded)) / newOffset 122 | : this.props.minItemHeight; 123 | 124 | this.setState({ 125 | offset: Math.min(newOffset, totalLength - length), 126 | }); 127 | this.avgRowHeight = Math.max(this.props.minItemHeight, newAvgRowHeight); 128 | } 129 | } else { 130 | const scrollDiff = items[start].getBoundingClientRect().y - el.getBoundingClientRect().y; 131 | if (scrollDiff > 0) { 132 | const offsetDiff = Math.floor(scrollDiff / this.props.minItemHeight) || 1; 133 | const newOffset = offset - offsetDiff; 134 | if (newOffset < totalLength - (length + buffer)) { 135 | this.setState({ 136 | offset: Math.max(0, newOffset), 137 | }); 138 | } 139 | } 140 | } 141 | 142 | this.lastScrollTop = scrollTop; 143 | } 144 | 145 | if (this.props.onScroll) this.props.onScroll(event); 146 | } 147 | 148 | renderItems(start, end) { 149 | const { 150 | renderItem 151 | } = this.props; 152 | 153 | return Array.from({ length: end - start + 1 }, (_, index) => { 154 | const rowIndex = start + index; 155 | const component = renderItem(rowIndex); 156 | return React.cloneElement( 157 | component, 158 | { 159 | key: rowIndex, 160 | className: ["VS-item", component.props.className].join(' ').trim() 161 | } 162 | ); 163 | }) 164 | } 165 | 166 | render() { 167 | const { 168 | totalLength, 169 | length, 170 | buffer, 171 | minItemHeight, 172 | forwardRef, 173 | offset: _offset, 174 | renderItem: _renderItem, 175 | ...rest 176 | } = this.props; 177 | 178 | const { 179 | init, 180 | offset, 181 | } = this.state; 182 | 183 | const { 184 | avgRowHeight 185 | } = this; 186 | 187 | const start = Math.max(0, offset - buffer); 188 | const end = Math.min(offset + (length + buffer) - 1, totalLength - 1); 189 | 190 | const topPadding = Math.max(0, start * avgRowHeight); 191 | const bottomPadding = Math.max(0, (totalLength - end - 1) * avgRowHeight); 192 | 193 | return ( 194 |
{ 197 | this.listRef = el; 198 | if (forwardRef) forwardRef.current = el; 199 | if (!init) this.setState({ init: true }); 200 | }} 201 | onScroll={this.onScrollHandler.bind(this)} 202 | > 203 | {init && ( 204 | <> 205 |
211 | {this.renderItems(start, end)} 212 |
218 | 219 | )} 220 |
221 | ); 222 | } 223 | } 224 | 225 | export default React.forwardRef((props, ref) => ); 226 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export const isInView = (container, element) => { 2 | const containerTop = container.offsetTop; 3 | const elementRect = element.getBoundingClientRect(); 4 | const elementTop = elementRect.top; 5 | const elementHeight = elementRect.height; 6 | 7 | return elementHeight - (containerTop - elementTop) > 0; 8 | } --------------------------------------------------------------------------------