diff --git a/src/List.tsx b/src/List.tsx index d71db85c..e777db5e 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -9,7 +9,7 @@ import type { InnerProps } from './Filler'; import type { ScrollBarDirectionType, ScrollBarRef } from './ScrollBar'; import ScrollBar from './ScrollBar'; import type { RenderFunc, SharedConfig, GetKey, ExtraRenderInfo } from './interface'; -import useChildren from './hooks/useChildren'; +import renderChildren from './utils/renderChildren'; import useHeights from './hooks/useHeights'; import useScrollTo from './hooks/useScrollTo'; import type { ScrollPos, ScrollTarget } from './hooks/useScrollTo'; @@ -21,6 +21,9 @@ import useLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect'; import { getSpinSize } from './utils/scrollbarUtil'; import { useEvent } from 'rc-util'; import { useGetSize } from './hooks/useGetSize'; +import type { ScrollToCacheState } from './utils/scrollToCacheState'; +import { MEASURE, STABLE } from './utils/scrollToCacheState'; +import useCalcPosition from './hooks/useCalcPosition'; const EMPTY_DATA = []; @@ -134,7 +137,7 @@ export function RawList(props: ListProps, ref: React.Ref) { if (typeof itemKey === 'function') { return itemKey(item); } - return item?.[itemKey]; + return item?.[itemKey as string]; }, [itemKey], ); @@ -144,6 +147,16 @@ export function RawList(props: ListProps, ref: React.Ref) { }; // ================================ Scroll ================================ + // If the scrollTo function is triggered to scroll to a location where nodes haven't cached their heights, the state needs to be switched to measure it. + const [scrollToCacheState, setScrollToCacheState] = React.useState(STABLE); + + useLayoutEffect(() => { + // componentRef shouldn't reset offsetTop when `scrollTo` is measuring + if (scrollToCacheState === STABLE && componentRef.current) { + componentRef.current.scrollTop = offsetTop; + } + }, [offsetTop, scrollToCacheState]); + function syncScrollTop(newTop: number | ((prev: number) => number)) { setOffsetTop((origin) => { let value: number; @@ -154,8 +167,6 @@ export function RawList(props: ListProps, ref: React.Ref) { } const alignedTop = keepInRange(value); - - componentRef.current.scrollTop = alignedTop; return alignedTop; }); } @@ -176,79 +187,20 @@ export function RawList(props: ListProps, ref: React.Ref) { ); // ========================== Visible Calculation ========================= - const { - scrollHeight, - start, - end, - offset: fillerOffset, - } = React.useMemo(() => { - if (!useVirtual) { - return { - scrollHeight: undefined, - start: 0, - end: mergedData.length - 1, - offset: undefined, - }; - } - - // Always use virtual scroll bar in avoid shaking - if (!inVirtual) { - return { - scrollHeight: fillerInnerRef.current?.offsetHeight || 0, - start: 0, - end: mergedData.length - 1, - offset: undefined, - }; - } - - let itemTop = 0; - let startIndex: number; - let startOffset: number; - let endIndex: number; - - const dataLen = mergedData.length; - for (let i = 0; i < dataLen; i += 1) { - const item = mergedData[i]; - const key = getKey(item); - - const cacheHeight = heights.get(key); - const currentItemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight); - - // Check item top in the range - if (currentItemBottom >= offsetTop && startIndex === undefined) { - startIndex = i; - startOffset = itemTop; - } - - // Check item bottom in the range. We will render additional one item for motion usage - if (currentItemBottom > offsetTop + height && endIndex === undefined) { - endIndex = i; - } - - itemTop = currentItemBottom; - } - - // When scrollTop at the end but data cut to small count will reach this - if (startIndex === undefined) { - startIndex = 0; - startOffset = 0; - - endIndex = Math.ceil(height / itemHeight); - } - if (endIndex === undefined) { - endIndex = mergedData.length - 1; - } - - // Give cache to improve scroll experience - endIndex = Math.min(endIndex + 1, mergedData.length - 1); - - return { - scrollHeight: itemTop, - start: startIndex, - end: endIndex, - offset: startOffset, - }; - }, [inVirtual, useVirtual, offsetTop, mergedData, heightUpdatedMark, height]); + const [scrollHeight, start, end, lastStartIndex, lastEndIndex, fillerOffset, maxScrollHeightRef] = + useCalcPosition( + scrollToCacheState, + fillerInnerRef, + getKey, + inVirtual, + heights, + itemHeight, + useVirtual, + offsetTop, + mergedData, + heightUpdatedMark, + height, + ); rangeRef.current.start = start; rangeRef.current.end = end; @@ -274,10 +226,6 @@ export function RawList(props: ListProps, ref: React.Ref) { ); // =============================== In Range =============================== - const maxScrollHeight = scrollHeight - height; - const maxScrollHeightRef = useRef(maxScrollHeight); - maxScrollHeightRef.current = maxScrollHeight; - function keepInRange(newScrollTop: number) { let newTop = newScrollTop; if (!Number.isNaN(maxScrollHeightRef.current)) { @@ -288,7 +236,7 @@ export function RawList(props: ListProps, ref: React.Ref) { } const isScrollAtTop = offsetTop <= 0; - const isScrollAtBottom = offsetTop >= maxScrollHeight; + const isScrollAtBottom = offsetTop >= maxScrollHeightRef.current; const originScroll = useOriginScroll(isScrollAtTop, isScrollAtBottom); @@ -434,6 +382,7 @@ export function RawList(props: ListProps, ref: React.Ref) { collectHeight, syncScrollTop, delayHideScrollBar, + setScrollToCacheState, ); React.useImperativeHandle(ref, () => ({ @@ -481,7 +430,44 @@ export function RawList(props: ListProps, ref: React.Ref) { }); // ================================ Render ================================ - const listChildren = useChildren( + const listChildren = React.useMemo(() => { + const nextChildren = renderChildren( + mergedData, + start, + end, + scrollWidth, + setInstanceRef, + children, + sharedConfig, + ); + + if (scrollToCacheState === STABLE || start === lastStartIndex) { + return nextChildren; + } else { + // ========== measure `cacheHeight` ============= + const nextChildrenWithScrollMeasure = renderChildren( + mergedData, + lastStartIndex, + lastEndIndex, + scrollWidth, + setInstanceRef, + children, + sharedConfig, + ); + + const childrenKey = new Set(nextChildrenWithScrollMeasure.map((item) => item.key)); + // de-duplicate to avoid `react` to render wrong nodes + nextChildren.forEach((item) => { + if (!childrenKey.has(item.key)) { + nextChildrenWithScrollMeasure.push(item); + } + }); + + // last children is always put on top, which due to `Filler`'s nodes is offseted by top, + return nextChildrenWithScrollMeasure; + } + }, [ + scrollToCacheState, mergedData, start, end, @@ -489,7 +475,7 @@ export function RawList(props: ListProps, ref: React.Ref) { setInstanceRef, children, sharedConfig, - ); + ]); let componentStyle: React.CSSProperties = null; if (height) { @@ -552,7 +538,8 @@ export function RawList(props: ListProps, ref: React.Ref) { ( + scrollToCacheState: ScrollToCacheState, + fillerInnerRef: React.MutableRefObject, + getKey: (item: T) => React.Key, + inVirtual: boolean, + heights: CacheMap, + itemHeight: number, + useVirtual: boolean, + offsetTop: number, + mergedData: T[], + heightUpdatedMark: number, + height: number, +): [number, number, number, number, number, number, React.MutableRefObject] { + const lastScrollInfos = useRef<[number, number, number, number]>([0, 0, 0, 0]); + const [lastScrollHeight, lastFillerOffset, lastStartIndex, lastEndIndex] = + lastScrollInfos.current; + + const maxScrollHeightRef = useRef(-1); + + const { + scrollHeight, + start, + end, + offset: fillerOffset, + } = React.useMemo(() => { + if (!useVirtual) { + return { + scrollHeight: undefined, + start: 0, + end: mergedData.length - 1, + offset: undefined, + }; + } + + // Always use virtual scroll bar in avoid shaking + if (!inVirtual) { + return { + scrollHeight: fillerInnerRef.current?.offsetHeight || 0, + start: 0, + end: mergedData.length - 1, + offset: undefined, + }; + } + + let itemTop = 0; + let startIndex: number; + let startOffset: number; + let endIndex: number; + + const dataLen = mergedData.length; + for (let i = 0; i < dataLen; i += 1) { + const item = mergedData[i]; + const key = getKey(item); + + const cacheHeight = heights.get(key); + const currentItemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight); + + // Check item top in the range + if (currentItemBottom >= offsetTop && startIndex === undefined) { + startIndex = i; + startOffset = itemTop; + } + + // Check item bottom in the range. We will render additional one item for motion usage + if (currentItemBottom > offsetTop + height && endIndex === undefined) { + endIndex = i; + } + + itemTop = currentItemBottom; + } + + // When scrollTop at the end but data cut to small count will reach this + if (startIndex === undefined) { + startIndex = 0; + startOffset = 0; + + endIndex = Math.ceil(height / itemHeight); + } + if (endIndex === undefined) { + endIndex = mergedData.length - 1; + } + + // Give cache to improve scroll experience + endIndex = Math.min(endIndex + 1, mergedData.length - 1); + + const result = { + scrollHeight: itemTop, + start: startIndex, + end: endIndex, + offset: startOffset, + }; + + // scrollToCacheState means listChildren is not correct, just use last offset to avoid seeing nothing. + if (scrollToCacheState === MEASURE) { + // makes `keepInRange` pass new `offsetTop` + maxScrollHeightRef.current = Math.max(lastScrollHeight, itemTop) - height; + + return { + ...result, + offset: lastFillerOffset, + scrollHeight: lastScrollHeight, + }; + } + + lastScrollInfos.current = [itemTop, startOffset, startIndex, endIndex]; + + return result; + }, [inVirtual, useVirtual, offsetTop, mergedData, heightUpdatedMark, height, scrollToCacheState]); + + // =============================== maxScrollHeight for `In Range` =============================== + const maxScrollHeight = scrollHeight - height; + // init + if (maxScrollHeightRef.current === -1) { + maxScrollHeightRef.current = maxScrollHeight; + } + if (scrollToCacheState !== MEASURE) { + maxScrollHeightRef.current = maxScrollHeight; + } + + return [scrollHeight, start, end, lastStartIndex, lastEndIndex, fillerOffset, maxScrollHeightRef]; +} diff --git a/src/hooks/useScrollTo.tsx b/src/hooks/useScrollTo.tsx index 4bc038f7..93095a13 100644 --- a/src/hooks/useScrollTo.tsx +++ b/src/hooks/useScrollTo.tsx @@ -3,6 +3,8 @@ import * as React from 'react'; import raf from 'rc-util/lib/raf'; import type { GetKey } from '../interface'; import type CacheMap from '../utils/CacheMap'; +import { MEASURE, STABLE } from '../utils/scrollToCacheState'; +import type { ScrollToCacheState } from '../utils/scrollToCacheState'; export type ScrollAlign = 'top' | 'bottom' | 'auto'; @@ -32,6 +34,7 @@ export default function useScrollTo( collectHeight: () => void, syncScrollTop: (newTop: number) => void, triggerFlash: () => void, + setScrollToCacheState: (state: ScrollToCacheState) => void, ): (arg: number | ScrollTarget) => void { const scrollRef = React.useRef(); @@ -63,6 +66,15 @@ export default function useScrollTo( const syncScroll = (times: number, targetAlign?: 'top' | 'bottom') => { if (times < 0 || !containerRef.current) return; + // ================== switch cacheState =================== + if (times === 3) { + setScrollToCacheState(MEASURE); + } else if (times === 0) { + setScrollToCacheState(STABLE); + } + + // ================== calculate targetTop ================= + const height = containerRef.current.clientHeight; let needCollectHeight = false; let newTargetAlign: 'top' | 'bottom' | null = targetAlign; diff --git a/src/hooks/useChildren.tsx b/src/utils/renderChildren.tsx similarity index 94% rename from src/hooks/useChildren.tsx rename to src/utils/renderChildren.tsx index 8b0bfe61..7552140c 100644 --- a/src/hooks/useChildren.tsx +++ b/src/utils/renderChildren.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import type { SharedConfig, RenderFunc } from '../interface'; import { Item } from '../Item'; -export default function useChildren( +export default function renderChildren( list: T[], startIndex: number, endIndex: number, diff --git a/src/utils/scrollToCacheState.ts b/src/utils/scrollToCacheState.ts new file mode 100644 index 00000000..d8ab0118 --- /dev/null +++ b/src/utils/scrollToCacheState.ts @@ -0,0 +1,5 @@ +export const STABLE = 0; +export const MEASURE = 1; + +/** When calling the method scorllTo, if the corresponding node does not have a cache height needs to be calculated */ +export type ScrollToCacheState = typeof STABLE | typeof MEASURE; diff --git a/tests/scroll.test.js b/tests/scroll.test.js index f1173920..f4603f68 100644 --- a/tests/scroll.test.js +++ b/tests/scroll.test.js @@ -15,7 +15,8 @@ describe('List.Scroll', () => { beforeAll(() => { mockElement = spyElementPrototypes(HTMLElement, { offsetHeight: { - get: () => 20, + // to imitate cacheHeight + get: () => 25, }, clientHeight: { get: () => 100, @@ -24,6 +25,10 @@ describe('List.Scroll', () => { width: 100, height: 100, }), + // make `collectHeight` work + offsetParent: { + get: () => true, + }, }); }); @@ -79,6 +84,28 @@ describe('List.Scroll', () => { }); describe('scroll to object', () => { + // scorllTops shouldn't have other values with `cacheHeight` pre-measure + const allowScrollTops = new Set([640, 2005, 0, 580, 800, 655]); + const passedScrollTops = new Set(); + let scrollTop = 0; + let scrollTopSpy; + + beforeAll(() => { + scrollTopSpy = spyElementPrototypes(HTMLElement, { + scrollTop: { + get: () => scrollTop, + set(_, val) { + passedScrollTops.add(val); + scrollTop = val; + }, + }, + }); + }); + + afterAll(() => { + scrollTopSpy.mockRestore(); + }); + const listRef = React.createRef(); const wrapper = genList({ itemHeight: 20, height: 100, data: genData(100), ref: listRef }); @@ -86,7 +113,7 @@ describe('List.Scroll', () => { it('work', () => { listRef.current.scrollTo({ index: 30, align: 'top' }); jest.runAllTimers(); - expect(wrapper.find('ul').instance().scrollTop).toEqual(600); + expect(wrapper.find('ul').instance().scrollTop).toEqual(640); }); it('out of range should not crash', () => { @@ -106,19 +133,24 @@ describe('List.Scroll', () => { it('key scroll', () => { listRef.current.scrollTo({ key: '30', align: 'bottom' }); jest.runAllTimers(); - expect(wrapper.find('ul').instance().scrollTop).toEqual(520); + expect(wrapper.find('ul').instance().scrollTop).toEqual(580); }); it('smart', () => { listRef.current.scrollTo(0); listRef.current.scrollTo({ index: 30 }); jest.runAllTimers(); - expect(wrapper.find('ul').instance().scrollTop).toEqual(520); + expect(wrapper.find('ul').instance().scrollTop).toEqual(580); listRef.current.scrollTo(800); listRef.current.scrollTo({ index: 30 }); jest.runAllTimers(); - expect(wrapper.find('ul').instance().scrollTop).toEqual(600); + // already got cacheHeight from bottom, so `scorllTop` is deifferent from `640` + expect(wrapper.find('ul').instance().scrollTop).toEqual(655); + }); + + it('should have not other scorllTop', () => { + expect(Array.from(passedScrollTops)).toEqual(Array.from(allowScrollTops)); }); });