Skip to content

Commit b0f5f92

Browse files
authored
fix: scrollTo flash (#232)
* refactor: use layout effect for scroll * refactor: record heights * chore: auto roll * chore: clean up * test: fix test case * test: update test
1 parent 1410c66 commit b0f5f92

File tree

5 files changed

+186
-106
lines changed

5 files changed

+186
-106
lines changed

examples/basic.tsx

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
1-
/* eslint-disable jsx-a11y/label-has-associated-control, jsx-a11y/label-has-for */
21
import * as React from 'react';
3-
import List, { ListRef } from '../src/List';
2+
import List, { type ListRef } from '../src/List';
43
import './basic.less';
54

65
interface Item {
7-
id: string;
6+
id: number;
87
}
98

109
const MyItem: React.ForwardRefRenderFunction<any, Item> = ({ id }, ref) => (
1110
<span
1211
ref={ref}
13-
// style={{
14-
// // height: 30 + (id % 2 ? 0 : 10),
15-
// }}
12+
style={{
13+
height: 30 + (id % 2 ? 0 : 10),
14+
}}
1615
className="fixed-item"
1716
onClick={() => {
1817
console.log('Click:', id);
@@ -35,7 +34,7 @@ class TestItem extends React.Component<Item, {}> {
3534
const data: Item[] = [];
3635
for (let i = 0; i < 1000; i += 1) {
3736
data.push({
38-
id: String(i),
37+
id: i,
3938
});
4039
}
4140

@@ -44,7 +43,7 @@ const TYPES = [
4443
{ name: 'ref react node', type: 'react', component: TestItem },
4544
];
4645

47-
const onScroll: React.UIEventHandler<HTMLElement> = e => {
46+
const onScroll: React.UIEventHandler<HTMLElement> = (e) => {
4847
console.log('scroll:', e.currentTarget.scrollTop);
4948
};
5049

@@ -160,7 +159,7 @@ const Demo = () => {
160159
type="button"
161160
onClick={() => {
162161
listRef.current.scrollTo({
163-
key: '50',
162+
key: 50,
164163
align: 'auto',
165164
});
166165
}}
@@ -171,7 +170,7 @@ const Demo = () => {
171170
<button
172171
type="button"
173172
onClick={() => {
174-
setVisible(v => !v);
173+
setVisible((v) => !v);
175174
}}
176175
>
177176
visible

src/List.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -431,7 +431,7 @@ export function RawList<T>(props: ListProps<T>, ref: React.Ref<ListRef>) {
431431
heights,
432432
itemHeight,
433433
getKey,
434-
collectHeight,
434+
() => collectHeight(true),
435435
syncScrollTop,
436436
delayHideScrollBar,
437437
);

src/hooks/useHeights.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ export default function useHeights<T>(
99
getKey: GetKey<T>,
1010
onItemAdd?: (item: T) => void,
1111
onItemRemove?: (item: T) => void,
12-
): [(item: T, instance: HTMLElement) => void, () => void, CacheMap, number] {
12+
): [
13+
setInstanceRef: (item: T, instance: HTMLElement) => void,
14+
collectHeight: (sync?: boolean) => void,
15+
cacheMap: CacheMap,
16+
updatedMark: number,
17+
] {
1318
const [updatedMark, setUpdatedMark] = React.useState(0);
1419
const instanceRef = useRef(new Map<React.Key, HTMLElement>());
1520
const heightsRef = useRef(new CacheMap());
@@ -19,10 +24,10 @@ export default function useHeights<T>(
1924
raf.cancel(collectRafRef.current);
2025
}
2126

22-
function collectHeight() {
27+
function collectHeight(sync = false) {
2328
cancelRaf();
2429

25-
collectRafRef.current = raf(() => {
30+
const doCollect = () => {
2631
instanceRef.current.forEach((element, key) => {
2732
if (element && element.offsetParent) {
2833
const htmlElement = findDOMNode<HTMLElement>(element);
@@ -35,7 +40,13 @@ export default function useHeights<T>(
3540

3641
// Always trigger update mark to tell parent that should re-calculate heights when resized
3742
setUpdatedMark((c) => c + 1);
38-
});
43+
};
44+
45+
if (sync) {
46+
doCollect();
47+
} else {
48+
collectRafRef.current = raf(doCollect);
49+
}
3950
}
4051

4152
function setInstanceRef(item: T, instance: HTMLElement) {

src/hooks/useScrollTo.tsx

Lines changed: 122 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import * as React from 'react';
33
import raf from 'rc-util/lib/raf';
44
import type { GetKey } from '../interface';
55
import type CacheMap from '../utils/CacheMap';
6+
import useLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect';
7+
import { warning } from 'rc-util';
8+
9+
const MAX_TIMES = 10;
610

711
export type ScrollAlign = 'top' | 'bottom' | 'auto';
812

@@ -35,6 +39,118 @@ export default function useScrollTo<T>(
3539
): (arg: number | ScrollTarget) => void {
3640
const scrollRef = React.useRef<number>();
3741

42+
const [syncState, setSyncState] = React.useState<{
43+
times: number;
44+
index: number;
45+
offset: number;
46+
originAlign: ScrollAlign;
47+
targetAlign?: 'top' | 'bottom';
48+
}>(null);
49+
50+
// ========================== Sync Scroll ==========================
51+
useLayoutEffect(() => {
52+
if (syncState && syncState.times < MAX_TIMES) {
53+
// Never reach
54+
if (!containerRef.current) {
55+
setSyncState((ori) => ({ ...ori }));
56+
return;
57+
}
58+
59+
collectHeight();
60+
61+
const { targetAlign, originAlign, index, offset } = syncState;
62+
63+
const height = containerRef.current.clientHeight;
64+
let needCollectHeight = false;
65+
let newTargetAlign: 'top' | 'bottom' | null = targetAlign;
66+
67+
// Go to next frame if height not exist
68+
if (height) {
69+
const mergedAlign = targetAlign || originAlign;
70+
71+
// Get top & bottom
72+
let stackTop = 0;
73+
let itemTop = 0;
74+
let itemBottom = 0;
75+
76+
const maxLen = Math.min(data.length, index);
77+
78+
for (let i = 0; i <= maxLen; i += 1) {
79+
const key = getKey(data[i]);
80+
itemTop = stackTop;
81+
const cacheHeight = heights.get(key);
82+
itemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight);
83+
84+
stackTop = itemBottom;
85+
}
86+
87+
// Check if need sync height (visible range has item not record height)
88+
let leftHeight = mergedAlign === 'top' ? offset : height - offset;
89+
for (let i = maxLen; i >= 0; i -= 1) {
90+
const key = getKey(data[i]);
91+
const cacheHeight = heights.get(key);
92+
93+
if (cacheHeight === undefined) {
94+
needCollectHeight = true;
95+
break;
96+
}
97+
98+
leftHeight -= cacheHeight;
99+
if (leftHeight <= 0) {
100+
break;
101+
}
102+
}
103+
104+
// Scroll to
105+
let targetTop: number | null = null;
106+
let inView = false;
107+
108+
switch (mergedAlign) {
109+
case 'top':
110+
targetTop = itemTop - offset;
111+
break;
112+
case 'bottom':
113+
targetTop = itemBottom - height + offset;
114+
break;
115+
116+
default: {
117+
const { scrollTop } = containerRef.current;
118+
const scrollBottom = scrollTop + height;
119+
if (itemTop < scrollTop) {
120+
newTargetAlign = 'top';
121+
} else if (itemBottom > scrollBottom) {
122+
newTargetAlign = 'bottom';
123+
} else {
124+
// No need to collect since already in view
125+
inView = true;
126+
}
127+
}
128+
}
129+
130+
if (targetTop !== null) {
131+
syncScrollTop(targetTop);
132+
} else if (!inView) {
133+
needCollectHeight = true;
134+
}
135+
}
136+
137+
// Trigger next effect
138+
if (needCollectHeight) {
139+
setSyncState((ori) => ({
140+
...ori,
141+
times: ori.times + 1,
142+
targetAlign: newTargetAlign,
143+
}));
144+
}
145+
} else if (process.env.NODE_ENV !== 'production' && syncState?.times === MAX_TIMES) {
146+
warning(
147+
false,
148+
'Seems `scrollTo` with `rc-virtual-list` reach toe max limitation. Please fire issue for us. Thanks.',
149+
);
150+
}
151+
}, [syncState, containerRef.current]);
152+
153+
// =========================== Scroll To ===========================
38154
return (arg) => {
39155
// When not argument provided, we think dev may want to show the scrollbar
40156
if (arg === null || arg === undefined) {
@@ -59,75 +175,12 @@ export default function useScrollTo<T>(
59175

60176
const { offset = 0 } = arg;
61177

62-
// We will retry 3 times in case dynamic height shaking
63-
const syncScroll = (times: number, targetAlign?: 'top' | 'bottom') => {
64-
if (times < 0 || !containerRef.current) return;
65-
66-
const height = containerRef.current.clientHeight;
67-
let needCollectHeight = false;
68-
let newTargetAlign: 'top' | 'bottom' | null = targetAlign;
69-
70-
// Go to next frame if height not exist
71-
if (height) {
72-
const mergedAlign = targetAlign || align;
73-
74-
// Get top & bottom
75-
let stackTop = 0;
76-
let itemTop = 0;
77-
let itemBottom = 0;
78-
79-
const maxLen = Math.min(data.length, index);
80-
81-
for (let i = 0; i <= maxLen; i += 1) {
82-
const key = getKey(data[i]);
83-
itemTop = stackTop;
84-
const cacheHeight = heights.get(key);
85-
itemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight);
86-
87-
stackTop = itemBottom;
88-
89-
if (i === index && cacheHeight === undefined) {
90-
needCollectHeight = true;
91-
}
92-
}
93-
94-
// Scroll to
95-
let targetTop: number | null = null;
96-
97-
switch (mergedAlign) {
98-
case 'top':
99-
targetTop = itemTop - offset;
100-
break;
101-
case 'bottom':
102-
targetTop = itemBottom - height + offset;
103-
break;
104-
105-
default: {
106-
const { scrollTop } = containerRef.current;
107-
const scrollBottom = scrollTop + height;
108-
if (itemTop < scrollTop) {
109-
newTargetAlign = 'top';
110-
} else if (itemBottom > scrollBottom) {
111-
newTargetAlign = 'bottom';
112-
}
113-
}
114-
}
115-
116-
if (targetTop !== null && targetTop !== containerRef.current.scrollTop) {
117-
syncScrollTop(targetTop);
118-
}
119-
}
120-
121-
// We will retry since element may not sync height as it described
122-
scrollRef.current = raf(() => {
123-
if (needCollectHeight) {
124-
collectHeight();
125-
}
126-
syncScroll(times - 1, newTargetAlign);
127-
}, 2); // Delay 2 to wait for List collect heights
128-
};
129-
130-
syncScroll(3);
178+
setSyncState({
179+
times: 0,
180+
index,
181+
offset,
182+
originAlign: align,
183+
});
131184
}
132185
};
133186
}

0 commit comments

Comments
 (0)