├── __mocks__ └── styleMock.js ├── src ├── index.d.ts ├── styles.css └── index.js ├── rtl.setup.js ├── __tests__ ├── .eslintrc ├── __snapshots__ │ └── index-test.js.snap └── index-test.js ├── .gitignore ├── jest.transform.js ├── demo └── src │ ├── SheepRow.js │ ├── ImageRow.js │ ├── index.css │ └── index.js ├── nwb.config.js ├── .travis.yml ├── jest.config.js ├── CONTRIBUTING.md ├── .github └── dependabot.yml ├── LICENSE ├── package.json └── README.md /__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-recycled-scrolling' -------------------------------------------------------------------------------- /rtl.setup.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect' -------------------------------------------------------------------------------- /__tests__/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /demo/dist 3 | /es 4 | /lib 5 | /node_modules 6 | /umd 7 | npm-debug.log* 8 | -------------------------------------------------------------------------------- /jest.transform.js: -------------------------------------------------------------------------------- 1 | module.exports = require('babel-jest').createTransformer({ 2 | presets: ['@babel/preset-env', '@babel/react'], 3 | ignore: [".css"] 4 | }); -------------------------------------------------------------------------------- /demo/src/SheepRow.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function SheepRow(no) { 4 | return ( 5 |
6 | {no} Sheep 7 |
8 | ) 9 | } -------------------------------------------------------------------------------- /nwb.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | type: 'react-component', 3 | npm: { 4 | esModules: true, 5 | umd: { 6 | global: 'ReactRecycledScrolling', 7 | externals: { 8 | react: 'React' 9 | } 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /demo/src/ImageRow.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function ImageRow({ no, url, alt }) { 4 | return ( 5 |
6 | Image number: {no}   7 | {alt}/ 8 |
9 | ) 10 | } -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | .DefaultContainer { 2 | width: 100%; 3 | height: 100%; 4 | position: relative; 5 | } 6 | 7 | .Wrapper { 8 | top: 0px; 9 | left: 0px; 10 | right: 0px; 11 | bottom: 0px; 12 | overflow-y: auto; 13 | position: absolute; 14 | } 15 | 16 | .ItemWrapper { 17 | left: 0px; 18 | right: 0px; 19 | position: absolute; 20 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: node_js 4 | node_js: 5 | - 8 6 | - 10 7 | - 12 8 | 9 | before_install: 10 | - npm install codecov.io coveralls 11 | 12 | after_success: 13 | - cat ./coverage/lcov.info | ./node_modules/codecov.io/bin/codecov.io.js 14 | - cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js 15 | 16 | branches: 17 | only: 18 | - master 19 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/index-test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`RecycledList should render 1`] = ` 4 |
7 |
11 |
19 |
20 |
21 | `; 22 | -------------------------------------------------------------------------------- /demo/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | text-align: center; 6 | } 7 | 8 | .Split { 9 | height: 100%; 10 | width: 50%; 11 | position: fixed; 12 | top: 0; 13 | } 14 | 15 | .Left { 16 | left: 0; 17 | background-color: #cfe1e638; 18 | } 19 | 20 | .Right { 21 | right: 0; 22 | } 23 | 24 | .ListItem { 25 | padding: 10px; 26 | position: relative; 27 | box-sizing: border-box; 28 | border-top: 1px solid darkgray; 29 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "collectCoverage": true, 3 | "coverageDirectory": "coverage", 4 | "verbose": true, 5 | "transform": { 6 | "^.+\\.js$": "/jest.transform.js" 7 | }, 8 | "testRegex": "(/__tests__/.*|\\.(test|spec))\\.jsx?$", 9 | "moduleFileExtensions": ["js", "json", "jsx"], 10 | "moduleNameMapper":{ 11 | "\\.(css|less|sass|scss)$": "/__mocks__/styleMock.js" 12 | }, 13 | "coverageThreshold": { 14 | "global": { 15 | "branches": 30, 16 | "functions": 90, 17 | "lines": 90, 18 | "statements": 90 19 | } 20 | }, 21 | setupFilesAfterEnv: ['./rtl.setup.js'] 22 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Prerequisites 2 | 3 | [Node.js](http://nodejs.org/) >= 6 must be installed. 4 | 5 | ## Installation 6 | 7 | - Running `npm install` in the component's root directory will install everything you need for development. 8 | 9 | ## Demo Development Server 10 | 11 | - `npm start` will run a development server with the component's demo app at [http://localhost:3000](http://localhost:3000) with hot module reloading. 12 | 13 | ## Running Tests 14 | 15 | - `npm test` will run the tests once. 16 | 17 | - `npm run test:coverage` will run the tests and produce a coverage report in `coverage/`. 18 | 19 | - `npm run test:watch` will run the tests on every change. 20 | 21 | ## Building 22 | 23 | - `npm run build` will build the component for publishing to npm and also bundle the demo app. 24 | 25 | - `npm run clean` will delete built resources. 26 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: "@babel/core" 10 | versions: 11 | - 7.12.10 12 | - 7.12.13 13 | - 7.12.16 14 | - 7.12.17 15 | - 7.13.1 16 | - 7.13.10 17 | - 7.13.13 18 | - 7.13.14 19 | - 7.13.15 20 | - 7.13.8 21 | - dependency-name: y18n 22 | versions: 23 | - 4.0.1 24 | - 4.0.2 25 | - dependency-name: "@testing-library/jest-dom" 26 | versions: 27 | - 5.11.10 28 | - 5.11.9 29 | - dependency-name: "@babel/preset-env" 30 | versions: 31 | - 7.12.11 32 | - 7.12.13 33 | - 7.12.16 34 | - 7.12.17 35 | - 7.13.0 36 | - 7.13.10 37 | - 7.13.12 38 | - 7.13.5 39 | - 7.13.8 40 | - 7.13.9 41 | - dependency-name: "@testing-library/react" 42 | versions: 43 | - 11.2.3 44 | - 11.2.5 45 | - dependency-name: "@babel/preset-react" 46 | versions: 47 | - 7.12.10 48 | - 7.12.13 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 sarons 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 | -------------------------------------------------------------------------------- /demo/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {render} from 'react-dom' 3 | 4 | import RecycledList from '../../src/index' 5 | import ImageRow from './ImageRow' 6 | import SheepRow from './SheepRow' 7 | 8 | import './index.css' 9 | 10 | const imageList = [] 11 | const numberList = [] 12 | for (let i = 1; i <= 1000; i++) { 13 | imageList.push({ 14 | no: i, 15 | alt: `thumbnail of ${i}.jpg`, 16 | url: `https://picsum.photos/id/${i}/100/100.jpg` 17 | }) 18 | } 19 | for (let i = 1; i <= 20000; i++) numberList.push(i) 20 | 21 | function Demo() { 22 | return ( 23 |
24 |
25 |

26 | Recycled Image List of {imageList.length} images 27 |

28 | 33 |
34 | 35 |
36 |

37 | Recycled List of {numberList.length} Sheep 38 |

39 | 44 |
45 |
46 | ) 47 | } 48 | 49 | render(, document.querySelector('#demo')) 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-recycled-scrolling", 3 | "version": "1.0.8", 4 | "description": "react-recycled-scrolling React component", 5 | "main": "lib/index.js", 6 | "module": "es/index.js", 7 | "files": ["css","es","lib","umd"], 8 | "scripts": { 9 | "build": "nwb build-react-component", 10 | "clean": "nwb clean-module && nwb clean-demo", 11 | "prepublishOnly": "npm run build -- --copy-files", 12 | "start": "nwb serve-react-demo", 13 | "test": "jest", 14 | "test:watch": "jest --watch", 15 | "test:coverage": "jest --coverage", 16 | "test:debug": "node --inspect-brk node_modules/.bin/jest --runInBand", 17 | "test:debugwatch": "node --inspect-brk node_modules/.bin/jest --runInBand --watch" 18 | }, 19 | "dependencies": {}, 20 | "peerDependencies": { 21 | "react": "16.x" 22 | }, 23 | "devDependencies": { 24 | "@babel/core": "^7.6.0", 25 | "@babel/preset-env": "^7.6.0", 26 | "@babel/preset-react": "^7.0.0", 27 | "@testing-library/jest-dom": "^5.1.1", 28 | "@testing-library/react": "^10.0.4", 29 | "babel-jest": "^26.1.0", 30 | "babel-loader": "^8.0.6", 31 | "jest": "^24.9.0", 32 | "nwb": "0.25.x", 33 | "react": "^16.9.0", 34 | "react-dom": "^16.9.0", 35 | "react-test-renderer": "^16.9.0" 36 | }, 37 | "author": "https://github.com/sarons", 38 | "homepage": "https://github.com/sarons/react-recycled-scrolling#react-recycled-scrolling", 39 | "license": "MIT", 40 | "repository": "https://github.com/sarons/react-recycled-scrolling", 41 | "keywords": [ 42 | "react", "react-hooks", "react-component", "scrolling", "recyclerview", 43 | "listview", "performance", "virtualization","list" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect, useRef} from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import './styles.css' 5 | 6 | RecycledList.propTypes = { 7 | attrList: PropTypes.array.isRequired, 8 | itemFn: PropTypes.func.isRequired, 9 | itemHeight: PropTypes.number, 10 | className: PropTypes.string, 11 | rowOffset: PropTypes.number 12 | } 13 | 14 | RecycledList.defaultProps = { 15 | attrList: [], 16 | itemHeight: 50, 17 | className: 'DefaultContainer', 18 | rowOffset: 6 19 | } 20 | 21 | export default function RecycledList({attrList, itemHeight, itemFn, className, rowOffset}) { 22 | const [scrollTop,setScrollTop] = useState(0) 23 | const [viewableHeight,setViewableHeight] = useState(0) 24 | const wrapper = useRef(null) 25 | const setScroll = e => setScrollTop(e.target.scrollTop) 26 | 27 | useEffect(()=> { 28 | if (wrapper.current) { 29 | setViewableHeight( parseFloat(window.getComputedStyle(wrapper.current).height) ) 30 | } 31 | },[]) 32 | 33 | const itemStyle = (index) => ({ 34 | height: itemHeight, 35 | top: itemHeight * index, 36 | }) 37 | const listStyle = () => ({ 38 | height: attrList.length * itemHeight, 39 | position: 'relative' 40 | }) 41 | 42 | const inView = position => (position < viewableHeight + scrollTop + (rowOffset - 1) * itemHeight && 43 | position > scrollTop - rowOffset * itemHeight) 44 | 45 | return( 46 |
47 |
48 |
49 | {attrList.map( 50 | (attrs, index) => inView(index * itemHeight) && 51 | (
52 | {itemFn(attrs)} 53 |
) 54 | ) 55 | } 56 |
57 |
58 |
59 | ) 60 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Recycled Scrolling 2 | 3 | ![npm](https://img.shields.io/npm/v/react-recycled-scrolling) 4 | [![Build Status](https://travis-ci.org/sarons/react-recycled-scrolling.svg?branch=master)](https://travis-ci.org/sarons/react-recycled-scrolling) 5 | [![Coverage Status](https://coveralls.io/repos/github/sarons/react-recycled-scrolling/badge.svg?branch=master)](https://coveralls.io/github/sarons/react-recycled-scrolling?branch=master) 6 | 7 | > Simulate normal scrolling by using only fixed number of DOM elements for large lists of items with React Hooks 8 | 9 | [npm-badge]: https://img.shields.io/npm/v/npm-package.png?style=flat-square 10 | [npm]: https://www.npmjs.org/package/npm-package 11 | 12 | ## Install 13 | 14 | ```bash 15 | npm install --save react-recycled-scrolling 16 | ``` 17 | 18 | ## Usage 19 | 20 | All that is required is 21 | * **attrList**: A list of items 22 | * **itemFn**: A React functional component or even just a function that returns jsx for each element 23 | 24 | ```javascript 25 | const numberList = [] 26 | for (let i = 1; i <= 20000; i++) numberList.push(i) 27 | const SheepRow = (no) => (
{no} Sheep
) 28 | ``` 29 | 30 | Then just drop in your RecycledList wherever you need it 31 | 32 | ```javascript 33 | import RecycledList from 'react-recycled-scrolling' 34 | 35 | 39 | ``` 40 | 41 | Additional parameters are 42 | * **itemHeight**: Height of each item, Default: 50 43 | * **rowOffset**: How many buffer rows you need outside the viewable screen, Default: 6 44 | * **className**: custom CSS for the outer scroll wrapper. You must have {position: relative} for recycled scroll to work 45 | 46 | ```javascript 47 | 54 | ``` 55 | 56 | ## Example 57 | 58 | [![Edit jovial-haibt-y8mlf](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/jovial-haibt-y8mlf?fontsize=14) 59 | 60 | https://codesandbox.io/s/jovial-haibt-y8mlf?fontsize=14 61 | 62 | ## License 63 | 64 | MIT 65 | -------------------------------------------------------------------------------- /__tests__/index-test.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect' 2 | import React from 'react' 3 | import renderer from 'react-test-renderer' 4 | import {render, fireEvent} from '@testing-library/react' 5 | import RecycledList from '../src/index' 6 | 7 | const renderWrapper = (numberList, itemHeight, extraRows, windowHeight, itemCount) =>{ 8 | for (let i = 1; i <= itemCount; i++) numberList.push(i) 9 | jest.spyOn(window,'getComputedStyle').mockImplementation(() => ({height: windowHeight})) 10 | const result = render( 11 | (<>{item})} 13 | attrList={numberList} 14 | itemHeight={itemHeight} 15 | rowOffset= {extraRows} 16 | /> 17 | ) 18 | return document.body.querySelector('.Wrapper').firstElementChild 19 | } 20 | 21 | describe('RecycledList', () => { 22 | it('should render', ()=>{ 23 | const result = renderer.create( 24 | 28 | ) 29 | expect(result).toMatchSnapshot() 30 | }) 31 | 32 | it('should render items only to fill screen, not all items', ()=>{ 33 | const numberList = [], itemHeight = 30, itemCount = 20000, 34 | extraRows = 10, windowHeight = 500 35 | const wrapper = renderWrapper(numberList, itemHeight, extraRows, 36 | windowHeight, itemCount) 37 | expect(wrapper.childElementCount).toBe(parseInt(windowHeight/itemHeight + extraRows)) 38 | }) 39 | 40 | it('should render all items when screen size > total renderable items', ()=>{ 41 | const numberList = [], itemHeight = 30, itemCount = 20, 42 | extraRows = 10, windowHeight = 500 43 | const wrapper = renderWrapper(numberList, itemHeight, extraRows, 44 | windowHeight, itemCount) 45 | expect(wrapper.childElementCount).toBe(itemCount) 46 | }) 47 | 48 | it('should remove items outside the view screen when scrolling down', ()=>{ 49 | const numberList = [], itemHeight = 30, itemCount = 20000, 50 | extraRows = 10, windowHeight = 500 51 | const wrapper = renderWrapper(numberList, itemHeight, extraRows, 52 | windowHeight, itemCount) 53 | const firstElement = wrapper.firstElementChild.innerHTML 54 | fireEvent.scroll(wrapper,{target: {scrollTop: windowHeight + itemHeight*extraRows}}) 55 | expect(wrapper.firstElementChild.innerHTML).not.toBe(firstElement) 56 | expect(parseInt(wrapper.firstElementChild.innerHTML)).toBeGreaterThan(extraRows) 57 | }) 58 | 59 | it('should remove items outside the view screen when scrolling up', ()=>{ 60 | const numberList = [], itemHeight = 30, itemCount = 20000, 61 | extraRows = 10, windowHeight = 500 62 | const wrapper = renderWrapper(numberList, itemHeight, extraRows, 63 | windowHeight, itemCount) 64 | fireEvent.scroll(wrapper,{target: {scrollTop: windowHeight + itemHeight*extraRows}}) 65 | const firstElement = wrapper.firstElementChild.innerHTML 66 | fireEvent.scroll(wrapper,{target: {scrollTop: 0}}) 67 | expect(wrapper.firstElementChild.innerHTML).not.toBe(firstElement) 68 | expect(parseInt(wrapper.firstElementChild.innerHTML)).toBe(1) 69 | }) 70 | 71 | it('should be able to scroll to the last item without rendering all items', ()=>{ 72 | const numberList = [], itemHeight = 30, itemCount = 20000, 73 | extraRows = 10, windowHeight = 500 74 | const wrapper = renderWrapper(numberList, itemHeight, extraRows, 75 | windowHeight, itemCount) 76 | const prevLastElement = parseInt(wrapper.lastElementChild.innerHTML) 77 | fireEvent.scroll(wrapper,{target: {scrollTop: parseInt(wrapper.style.height)}}) 78 | expect(parseInt(wrapper.firstElementChild.innerHTML)).toBeGreaterThan(prevLastElement) 79 | expect(parseInt(wrapper.lastElementChild.innerHTML)).toBe(numberList[itemCount-1]) 80 | }) 81 | }) 82 | --------------------------------------------------------------------------------