Last active
April 7, 2022 05:55
-
-
Save limgit/00f88d626e313d5dd4c8446c4db18e51 to your computer and use it in GitHub Desktop.
WIP: Full screen react list virtualizer with infinite scrolling
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 React from 'react'; | |
| const GUESSED_HEIGHT = 80; // Initial height value we will use for unknown itme height | |
| type Data = { | |
| id: number, | |
| value: string, | |
| }; | |
| type VirtualScrollerProps = { | |
| dataList: Data[], // Data to render | |
| loadingMore: boolean, // Is data loading more? | |
| hasNext: boolean, // Does next data exist? | |
| onLoadMore: () => void, // Callback to load more data | |
| }; | |
| const VirtualScroller: React.FC<VirtualScrollerProps> = ({ | |
| dataList, loadingMore, hasNext, onLoadMore, | |
| }) => { | |
| const itemsRef = React.useRef<(HTMLDivElement | null)[]>([]); | |
| type ItemHeight = { | |
| [idx: number]: number, | |
| }; | |
| const [itemHeights, setItemHeights] = React.useState<ItemHeight>({}); | |
| const [visible, setVisible] = React.useState({ | |
| startIdx: 0, | |
| // For initial value, we assume all items have GUESSED_HEIGHT height value. | |
| // In such case, we can see Math.ceil(document.documentElement.clientHeight / GUESS_HEIGHT) items in screen | |
| // Multiply it by 2 for additional buffering of items so client can draw next items beforehand | |
| count: Math.ceil(document.documentElement.clientHeight / GUESSED_HEIGHT) * 2, | |
| }); | |
| const updateItemHeights = () => { | |
| const newV: ItemHeight = {}; | |
| const { startIdx, count } = visible; | |
| let needUpdate = false; | |
| dataList.slice(startIdx, startIdx + count).forEach((_, idx) => { | |
| const originalIdx = startIdx + idx; | |
| // Get rendered item height | |
| const elHeight = itemsRef.current[originalIdx]?.offsetHeight; | |
| // Update item height if needed | |
| if (elHeight !== undefined && itemHeights[originalIdx] !== elHeight) { | |
| needUpdate = true; | |
| newV[originalIdx] = elHeight; | |
| } | |
| }); | |
| if (needUpdate) { | |
| setItemHeights({ | |
| ...itemHeights, | |
| ...newV, | |
| }); | |
| } | |
| }; | |
| // Hook for adjusting ref array size | |
| React.useEffect(() => { | |
| itemsRef.current = itemsRef.current.slice(0, dataList.length); | |
| }, [dataList.length]); | |
| // On every render, recalculate item heights and if it changes, we render the component again | |
| React.useEffect(() => { | |
| updateItemHeights(); | |
| }); | |
| // Registering resize event handler | |
| React.useEffect(() => { | |
| const onResize = () => { | |
| // Maybe we will need rAF for event throttling | |
| // Recalculate item heights on resize | |
| updateItemHeights(); | |
| }; | |
| window.addEventListener('resize', onResize); | |
| return () => { | |
| window.removeEventListener('resize', onResize); | |
| }; | |
| }); | |
| // Registering scroll event handler | |
| React.useEffect(() => { | |
| const onScroll = () => { | |
| const { scrollHeight, scrollTop, clientHeight } = document.documentElement; | |
| // Check if user reaches the end of scroll. If so, load more items | |
| if (scrollTop + clientHeight >= scrollHeight && !loadingMore && hasNext) { | |
| onLoadMore(); | |
| } | |
| // Calculate visible items and set it if it changes | |
| setVisible((prev) => { | |
| let sum = 0; | |
| let sIdx = 0; | |
| let count = 0; | |
| for (let i = 0; i < dataList.length; i += 1) { | |
| sum += itemHeights[i] ?? GUESSED_HEIGHT; | |
| if (sum <= scrollTop) sIdx = i; | |
| if (sum > scrollTop && sum <= scrollTop + clientHeight) count += 1; | |
| } | |
| // For buffering, we will load some more items before/after visible section | |
| const newStart = Math.max(0, sIdx - Math.floor(count / 2)); | |
| const newCount = count * 2; | |
| if (newStart !== prev.startIdx || newCount !== prev.count) { | |
| return { | |
| startIdx: newStart, | |
| count: newCount, | |
| }; | |
| } | |
| return prev; | |
| }); | |
| }; | |
| window.addEventListener('scroll', onScroll); | |
| return () => { | |
| window.removeEventListener('scroll', onScroll); | |
| }; | |
| }); | |
| // Hook for loading items if item list does not overflow | |
| React.useEffect(() => { | |
| const sH = document.documentElement.scrollHeight; | |
| const cH = document.documentElement.clientHeight; | |
| if (cH >= sH && !loadingMore && hasNext) { | |
| // Data does not overflow the screen. Load more. | |
| onLoadMore(); | |
| } | |
| }); | |
| // Sum item heights until idx (exclusive) | |
| const sumHeightUntil = (idx: number) => new Array(idx).fill(null).reduce<number>( | |
| (acc, _, i) => acc + (itemHeights[i] ?? GUESSED_HEIGHT), | |
| 0, | |
| ); | |
| const { startIdx, count } = visible; | |
| return ( | |
| <> | |
| <div style={{ position: 'relative', height: sumHeightUntil(dataList.length) }}> | |
| {dataList.slice(startIdx, startIdx + count).map((data, partialIdx) => { | |
| const originalIdx = startIdx + partialIdx; | |
| const { id, value } = data; | |
| return ( | |
| <div | |
| key={id} | |
| ref={(el) => { itemsRef.current[originalIdx] = el; }} | |
| style={{ | |
| position: 'absolute', | |
| left: 0, | |
| top: sumHeightUntil(originalIdx), | |
| }} | |
| > | |
| {value} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| {hasNext && ( | |
| <div style={{ textAlign: 'center' }}> | |
| {loadingMore ? 'Loading...' : 'Scroll to load more'} | |
| </Box> | |
| )} | |
| </> | |
| ); | |
| }; | |
| export default VirtualScroller; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment