diff --git a/src/index.js b/src/index.js index 7e480f7a..4f1a9e06 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,5 @@ import React from 'react' -import useScroll from './useScroll' import useRect from './useRect' import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect' @@ -18,23 +17,15 @@ export function useVirtual({ }) { const sizeKey = horizontal ? 'width' : 'height' const scrollKey = horizontal ? 'scrollLeft' : 'scrollTop' + const latestRef = React.useRef({}) const { [sizeKey]: outerSize } = useRect(parentRef) || { [sizeKey]: 0, } - const [scrollOffset, _setScrollOffset] = React.useState(0) - - const scrollOffsetPlusOuterSize = scrollOffset + outerSize - - useScroll(parentRef, ({ [scrollKey]: newScrollOffset }) => { - _setScrollOffset(newScrollOffset) - }) - const defaultScrollToFn = React.useCallback( offset => { if (parentRef.current) { - _setScrollOffset(offset) parentRef.current[scrollKey] = offset } }, @@ -52,69 +43,56 @@ export function useVirtual({ const [measuredCache, setMeasuredCache] = React.useState({}) - const { measurements, reversedMeasurements } = React.useMemo(() => { + const measurements = React.useMemo(() => { const measurements = [] - const reversedMeasurements = [] - - for (let i = 0, j = size - 1; i < size; i++, j--) { + for (let i = 0; i < size; i++) { const measuredSize = measuredCache[i] const start = measurements[i - 1] ? measurements[i - 1].end : paddingStart const size = typeof measuredSize === 'number' ? measuredSize : estimateSize(i) const end = start + size - const bounds = { index: i, start, size, end } - measurements[i] = { - ...bounds, - } - reversedMeasurements[j] = { - ...bounds, - } + measurements[i] = { index: i, start, size, end } } - return { measurements, reversedMeasurements } + return measurements }, [estimateSize, measuredCache, paddingStart, size]) const totalSize = (measurements[size - 1]?.end || 0) + paddingEnd - let start = React.useMemo( - () => - reversedMeasurements.reduce( - (last, rowStat) => (rowStat.end >= scrollOffset ? rowStat : last), - reversedMeasurements[0] - ), - [reversedMeasurements, scrollOffset] - ) + Object.assign(latestRef.current, { + overscan, + measurements, + outerSize, + totalSize, + }) - let end = React.useMemo( - () => - measurements.reduce( - (last, rowStat) => - rowStat.start <= scrollOffsetPlusOuterSize ? rowStat : last, - measurements[0] - ), - [measurements, scrollOffsetPlusOuterSize] - ) + const [range, setRange] = React.useState({ start: 0, end: 0 }) - let startIndex = start ? start.index : 0 - let endIndex = end ? end.index : 0 + useIsomorphicLayoutEffect(() => { + const element = parentRef.current - // Always add at least one overscan item, so focus will work - startIndex = Math.max(startIndex - overscan, 0) - endIndex = Math.min(endIndex + overscan, size - 1) + const onScroll = () => { + const scrollOffset = element[scrollKey] + latestRef.current.scrollOffset = scrollOffset + setRange(prevRange => calculateRange(latestRef.current, prevRange)) + } - const latestRef = React.useRef({}) + // Determine initially visible range + onScroll() - latestRef.current = { - measurements, - outerSize, - scrollOffset, - scrollOffsetPlusOuterSize, - totalSize, - } + element.addEventListener('scroll', onScroll, { + capture: false, + passive: true, + }) + + return () => { + element.removeEventListener('scroll', onScroll) + } + }, [parentRef.current, scrollKey, size /* required */]) const virtualItems = React.useMemo(() => { const virtualItems = [] - for (let i = startIndex; i <= endIndex; i++) { + for (let i = range.start; i <= range.end; i++) { const measurement = measurements[i] const item = { @@ -143,7 +121,7 @@ export function useVirtual({ } return virtualItems - }, [startIndex, endIndex, measurements, sizeKey, defaultScrollToFn]) + }, [range.start, range.end, measurements, sizeKey, defaultScrollToFn]) const mountedRef = React.useRef() @@ -156,16 +134,12 @@ export function useVirtual({ const scrollToOffset = React.useCallback( (toOffset, { align = 'start' } = {}) => { - const { - outerSize, - scrollOffset, - scrollOffsetPlusOuterSize, - } = latestRef.current + const { scrollOffset, outerSize } = latestRef.current if (align === 'auto') { if (toOffset <= scrollOffset) { align = 'start' - } else if (scrollOffset >= scrollOffsetPlusOuterSize) { + } else if (scrollOffset >= scrollOffset + outerSize) { align = 'end' } else { align = 'start' @@ -185,11 +159,7 @@ export function useVirtual({ const tryScrollToIndex = React.useCallback( (index, { align = 'auto', ...rest } = {}) => { - const { - measurements, - scrollOffset, - scrollOffsetPlusOuterSize, - } = latestRef.current + const { measurements, scrollOffset, outerSize } = latestRef.current const measurement = measurements[Math.max(0, Math.min(index, size - 1))] @@ -198,7 +168,7 @@ export function useVirtual({ } if (align === 'auto') { - if (measurement.end >= scrollOffsetPlusOuterSize) { + if (measurement.end >= scrollOffset + outerSize) { align = 'end' } else if (measurement.start <= scrollOffset) { align = 'start' @@ -241,3 +211,30 @@ export function useVirtual({ scrollToIndex, } } + +function calculateRange({ + overscan, + measurements, + outerSize, + scrollOffset, +}, prevRange) { + const total = measurements.length + let start = total - 1 + while (start > 0 && measurements[start].end >= scrollOffset) { + start -= 1 + } + let end = 0 + while (end < total - 1 && measurements[end].start <= scrollOffset + outerSize) { + end += 1 + } + + // Always add at least one overscan item, so focus will work + start = Math.max(start - overscan, 0) + end = Math.min(end + overscan, total - 1) + + if (!prevRange || prevRange.start !== start || prevRange.end !== end) { + return { start, end } + } + + return prevRange +} diff --git a/src/useRect.js b/src/useRect.js index 24b34238..1b679608 100644 --- a/src/useRect.js +++ b/src/useRect.js @@ -6,7 +6,7 @@ import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect' export default function useRect(nodeRef) { const [element, setElement] = React.useState(nodeRef.current) - const [rect, dispatch] = React.useReducer(rectReducer, null); + const [rect, dispatch] = React.useReducer(rectReducer, null) const initialRectSet = React.useRef(false) useIsomorphicLayoutEffect(() => { @@ -43,10 +43,10 @@ export default function useRect(nodeRef) { } function rectReducer(state, action) { - const rect = action.rect; + const rect = action.rect if (!state || state.height !== rect.height || state.width !== rect.width) { - return rect; + return rect } - return state; + return state } diff --git a/src/useScroll.js b/src/useScroll.js deleted file mode 100644 index 2a127b9b..00000000 --- a/src/useScroll.js +++ /dev/null @@ -1,46 +0,0 @@ -import React from 'react' - -import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect' - -export default function useScroll(nodeRef, onChange) { - const [element, setElement] = React.useState(nodeRef.current) - const onChangeRef = React.useRef() - onChangeRef.current = onChange - - useIsomorphicLayoutEffect(() => { - if (nodeRef.current !== element) { - setElement(nodeRef.current) - } - }) - - useIsomorphicLayoutEffect(() => { - if (element) { - onChangeRef.current({ - scrollLeft: element.scrollLeft, - scrollTop: element.scrollTop, - }) - } - }, [element]) - - React.useEffect(() => { - if (!element) { - return - } - - const handler = e => { - onChangeRef.current({ - scrollLeft: e.target.scrollLeft, - scrollTop: e.target.scrollTop, - }) - } - - element.addEventListener('scroll', handler, { - capture: false, - passive: true, - }) - - return () => { - element.removeEventListener('scroll', handler) - } - }, [element]) -}