├── __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 |

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 |
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 | 
4 | [](https://travis-ci.org/sarons/react-recycled-scrolling)
5 | [](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 | [](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 |
--------------------------------------------------------------------------------