Skip to content

Instantly share code, notes, and snippets.

@mogelbrod
Last active July 13, 2022 21:43
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save mogelbrod/7029848de62016bf0f0b38fb56f8da3e to your computer and use it in GitHub Desktop.
Save mogelbrod/7029848de62016bf0f0b38fb56f8da3e to your computer and use it in GitHub Desktop.
useVirtualScrollParent() hook using useVirtual() from react-virtual
const html = document.documentElement
const body = document.body
/**
Pseudo HTML element which behaves like a regular element in terms of scrolling.
*/
const documentElement = {
set scrollLeft(x) { html.scrollLeft = x },
get scrollLeft() { return window.scrollX || window.pageXOffset },
set scrollTop(x) { html.scrollTop = x },
get scrollTop() { return window.scrollY || window.pageYOffset },
get scrollWidth() {
return Math.max(
body.scrollWidth, html.scrollWidth,
body.offsetWidth, html.offsetWidth,
body.clientWidth, html.clientWidth,
)
},
get scrollHeight() {
return Math.max(
body.scrollHeight, html.scrollHeight,
body.offsetHeight, html.offsetHeight,
body.clientHeight, html.clientHeight,
)
},
get clientWidth() { return html.clientWidth },
get clientHeight() { return html.clientHeight },
getBoundingClientRect() {
return {
x: 0,
y: 0,
top: 0,
left: 0,
width: this.clientWidth,
height: this.clientHeight,
}
},
addEventListener(event, ...args) {
const target = event === 'scroll' ? window : html
return target.addEventListener(event, ...args)
},
removeEventListener(event, ...args) {
const target = event === 'scroll' ? window : html
return target.removeEventListener(event, ...args)
},
}
export default documentElement
import React from 'react'
import { useVirtual } from 'react-virtual'
import documentElement from './document-element'
export default function useVirtualScrollParent(options) {
const sizeKey = options.horizontal ? 'width' : 'height'
const { parentRef } = options
const [rowSize, setRowSize] = React.useState(options.estimateSize(0))
// Mock the API surface currently used through parentRef
const mockedParentRef = React.useRef(null)
React.useLayoutEffect(() => {
const scrolled = parentRef.current
if (!scrolled) { return }
let scrollParent = findScrollParent(scrolled)
if (scrollParent === document.documentElement) {
scrollParent = documentElement
}
let originalScrollListener = null
const scrollListener = (originalEvent) => {
// Compensate for potential <body> offset (due to disabled scrolling for example)
const bodyOffset = scrollParent === documentElement
? parseInt(document.body.style.top || '0', 10)
: 0
const target = {
scrollLeft: scrollParent.scrollLeft - scrolled.offsetLeft,
scrollTop: scrollParent.scrollTop - scrolled.offsetTop - bodyOffset,
}
originalScrollListener({ target })
}
mockedParentRef.current = {
get scrollLeft() {
return scrollParent.scrollLeft - scrolled.offsetLeft
},
set scrollLeft(x) {
scrollParent.scrollLeft = x + scrolled.offsetLeft
},
get scrollTop() {
return scrollParent.scrollTop - scrolled.offsetTop
},
set scrollTop(x) {
scrollParent.scrollTop = x + scrolled.offsetTop
},
getBoundingClientRect: () => ({
width: scrollParent.clientWidth,
height: scrollParent.clientHeight,
}),
addEventListener: (type, listener, ...args) => {
// Only proxy 'scroll' event listeners
if (type === 'scroll') {
originalScrollListener = listener
listener = scrollListener
args = [] // has to be reset for IE11 to be able to later remove it
listener()
}
return scrollParent.addEventListener(type, listener, ...args)
},
removeEventListener: (type, listener, ...args) => {
if (type === 'scroll') {
listener = scrollListener
args = []
}
return scrollParent.removeEventListener(type, listener, ...args)
}
}
}, [parentRef])
const rowVirtualizer = useVirtual({
...options,
parentRef: mockedParentRef,
estimateSize: React.useCallback(() => rowSize, [rowSize]),
})
// Calculate row size from first item only
const sizedElementRef = React.useRef()
const estimateSizeRef = React.useCallback(element => {
sizedElementRef.current = element
if (!element) { return }
setRowSize(element.getBoundingClientRect()[sizeKey] + 1)
}, [setRowSize, sizeKey])
// Re-calculate row size on window resize
React.useLayoutEffect(() => {
const onResize = () => {
if (!sizedElementRef.current) { return }
setRowSize(sizedElementRef.current.getBoundingClientRect()[sizeKey] + 1)
}
onResize()
window.addEventListener('resize', onResize)
return () => { window.removeEventListener('resize', onResize) }
}, [sizeKey, estimateSizeRef])
return {
...rowVirtualizer,
estimateSizeRef,
}
}
function findScrollParent(node) {
const scrollingElement = document.scrollingElement || document.documentElement
if (!(node instanceof HTMLElement || node instanceof SVGElement)) {
return scrollingElement
}
let style = getComputedStyle(node)
if (style.position === 'fixed') {
return scrollingElement
}
const excludeStaticParent = style.position === 'absolute'
const overflowRegex = /(auto|scroll|overlay)/
let parent = node.parentElement
while (parent) {
style = getComputedStyle(parent)
if (excludeStaticParent && style.position === 'static') {
continue
}
if (overflowRegex.test(style.overflow + style.overflowY + style.overflowX)) {
return parent === document.body ? scrollingElement : parent
}
parent = parent.parentElement
}
return scrollingElement
}
@petersendidit
Copy link

what does findScrollParent do?

@mogelbrod
Copy link
Author

what does findScrollParent do?

Looks like I forgot to rename the scrollParent() helper - it should be named findScrollParent() to the naming collision with the variable.
I've updated the gist now!

@thanhlmm
Copy link

Hi, can you show how to use it with window as parentRef?

@mogelbrod
Copy link
Author

mogelbrod commented Nov 16, 2021

Hi, can you show how to use it with window as parentRef?

Hi @thanhlmm! You should be able to use it the same way you use useVirtual() from react-virtual:

function ScrolledComponent() {
  const items = Array.apply(null, {length: 10000}).map((_, i) => i+1) // the item array
  const virtualizer = useVirtualScrollParent({
    parentRef,
    size: items.length,
    estimateSize: React.useCallback(() => 40, []), // should return height of each item
  })
  return <div ref={parentRef} style={{ position: 'relative', height: virtualizer.totalSize + 'px' }}>
    {virtualizer.virtualItems.map((row, offset) => {
      const style = {
        position: 'absolute',
        top: 0,
        left: 0,
        width: '100%',
        transform: `translateY(${virtualRow.start}px)`,
      }
      return <div style={style}>{item[virtualRow.index]}</div>
    })}
  </div>
}

@xaun
Copy link

xaun commented Mar 24, 2022

target undefined?

@mogelbrod
Copy link
Author

target undefined?

Good catch, I've updated the code now so that it should be correct (target => scrollParent)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment