Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 72 additions & 85 deletions src/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 = [];

Expand Down Expand Up @@ -134,7 +137,7 @@ export function RawList<T>(props: ListProps<T>, ref: React.Ref<ListRef>) {
if (typeof itemKey === 'function') {
return itemKey(item);
}
return item?.[itemKey];
return item?.[itemKey as string];
},
[itemKey],
);
Expand All @@ -144,6 +147,16 @@ export function RawList<T>(props: ListProps<T>, ref: React.Ref<ListRef>) {
};

// ================================ 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<ScrollToCacheState>(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;
Expand All @@ -154,8 +167,6 @@ export function RawList<T>(props: ListProps<T>, ref: React.Ref<ListRef>) {
}

const alignedTop = keepInRange(value);

componentRef.current.scrollTop = alignedTop;
return alignedTop;
});
}
Expand All @@ -176,79 +187,20 @@ export function RawList<T>(props: ListProps<T>, ref: React.Ref<ListRef>) {
);

// ========================== 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;
Expand All @@ -274,10 +226,6 @@ export function RawList<T>(props: ListProps<T>, ref: React.Ref<ListRef>) {
);

// =============================== 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)) {
Expand All @@ -288,7 +236,7 @@ export function RawList<T>(props: ListProps<T>, ref: React.Ref<ListRef>) {
}

const isScrollAtTop = offsetTop <= 0;
const isScrollAtBottom = offsetTop >= maxScrollHeight;
const isScrollAtBottom = offsetTop >= maxScrollHeightRef.current;

const originScroll = useOriginScroll(isScrollAtTop, isScrollAtBottom);

Expand Down Expand Up @@ -434,6 +382,7 @@ export function RawList<T>(props: ListProps<T>, ref: React.Ref<ListRef>) {
collectHeight,
syncScrollTop,
delayHideScrollBar,
setScrollToCacheState,
);

React.useImperativeHandle(ref, () => ({
Expand Down Expand Up @@ -481,15 +430,52 @@ export function RawList<T>(props: ListProps<T>, ref: React.Ref<ListRef>) {
});

// ================================ 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,
scrollWidth,
setInstanceRef,
children,
sharedConfig,
);
]);

let componentStyle: React.CSSProperties = null;
if (height) {
Expand Down Expand Up @@ -552,7 +538,8 @@ export function RawList<T>(props: ListProps<T>, ref: React.Ref<ListRef>) {
<ScrollBar
ref={verticalScrollBarRef}
prefixCls={prefixCls}
scrollOffset={offsetTop}
// if need cache height, just use last scrollTop
scrollOffset={scrollToCacheState === MEASURE ? componentRef.current.scrollTop : offsetTop}
scrollRange={scrollHeight}
rtl={isRTL}
onScroll={onScrollBar}
Expand Down
126 changes: 126 additions & 0 deletions src/hooks/useCalcPosition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import type CacheMap from '../utils/CacheMap';
import type { ScrollToCacheState } from '../utils/scrollToCacheState';
import { MEASURE } from '../utils/scrollToCacheState';
import React, { useRef } from 'react';

export default function useCalcPosition<T>(
scrollToCacheState: ScrollToCacheState,
fillerInnerRef: React.MutableRefObject<HTMLDivElement>,
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<number>] {
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];
}
Loading