Skip to content

Commit 9f13924

Browse files
committed
virtual scroll it
1 parent 72d4ac9 commit 9f13924

File tree

6 files changed

+128
-32
lines changed

6 files changed

+128
-32
lines changed

.eslintrc.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,10 @@ const base = require('@umijs/fabric/dist/eslint');
22

33
module.exports = {
44
...base,
5+
rules: {
6+
...base.rules,
7+
'@typescript-eslint/no-explicit-any': 0,
8+
'react/no-did-update-set-state': 0,
9+
'react/no-find-dom-node': 0,
10+
},
511
};

examples/basic.tsx

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,25 +25,55 @@ const MyItem: React.FC<Item> = ({ id }, ref) => {
2525

2626
const ForwardMyItem = React.forwardRef(MyItem);
2727

28+
class TestItem extends React.Component {
29+
render() {
30+
return <div style={{ lineHeight: '30px' }}>{this.props.id}</div>;
31+
}
32+
}
33+
2834
const dataSource: Item[] = [];
2935
for (let i = 0; i < 100; i += 1) {
3036
dataSource.push({
3137
id: i,
3238
});
3339
}
3440

41+
const TYPES = [
42+
{ name: 'ref real dom element', type: 'dom', component: ForwardMyItem },
43+
{ name: 'ref react node', type: 'react', component: TestItem },
44+
];
45+
3546
const Demo = () => {
47+
const [type, setType] = React.useState('dom');
48+
3649
return (
3750
<React.StrictMode>
3851
<div>
3952
<h2>Basic</h2>
53+
{TYPES.map(({ name, type: nType }) => (
54+
<label key={nType}>
55+
<input
56+
name="type"
57+
type="radio"
58+
checked={type === nType}
59+
onChange={() => {
60+
setType(nType);
61+
}}
62+
/>
63+
{name}
64+
</label>
65+
))}
66+
4067
<List
4168
dataSource={dataSource}
4269
height={200}
4370
itemHeight={30}
44-
style={{ border: '1px solid red', boxSizing: 'border-box' }}
71+
style={{
72+
border: '1px solid red',
73+
boxSizing: 'border-box',
74+
}}
4575
>
46-
{item => <ForwardMyItem {...item} />}
76+
{item => (type === 'dom' ? <ForwardMyItem {...item} /> : <TestItem {...item} />)}
4777
</List>
4878
</div>
4979
</React.StrictMode>

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@
3535
"now-build": "npm run build"
3636
},
3737
"peerDependencies": {
38-
"react": "*"
38+
"react": "*",
39+
"react-dom": "*"
3940
},
4041
"devDependencies": {
4142
"@types/lodash": "^4.14.135",

src/Filler.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
import * as React from 'react';
22

33
interface FillerProps {
4+
/** Virtual filler height. Should be `count * itemMinHeight` */
45
height: number;
6+
/** Set offset of visible items. Should be the top of start item position */
7+
offset: number;
8+
9+
children: React.ReactNode;
510
}
611

712
/**
813
* Fill component to provided the scroll content real height.
914
*/
10-
const Filler: React.FC<FillerProps> = ({ height, children }) => (
15+
const Filler: React.FC<FillerProps> = ({ height, offset, children }): React.ReactElement => (
1116
<div style={{ height, position: 'relative', overflow: 'hidden' }}>
1217
<div
1318
style={{
19+
marginTop: offset,
1420
position: 'absolute',
1521
left: 0,
1622
right: 0,

src/List.tsx

Lines changed: 60 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import * as React from 'react';
2-
import { findDOMNode } from 'react-dom';
32
import Filler from './Filler';
4-
import { getLocationItem, getScrollPercentage } from './util';
3+
import { getLocationItem, getScrollPercentage, getNodeHeight } from './util';
54

65
type RenderFunc<T> = (item: T) => React.ReactNode;
76

@@ -16,6 +15,8 @@ export interface ListProps<T> extends React.HTMLAttributes<any> {
1615
interface ListState {
1716
status: 'NONE' | 'MEASURE_START' | 'MEASURE_DONE';
1817

18+
scrollTop: number | null;
19+
scrollPtg: number;
1920
itemIndex: number;
2021
itemOffsetPtg: number;
2122
startIndex: number;
@@ -40,6 +41,8 @@ class List<T> extends React.Component<ListProps<T>, ListState> {
4041

4142
state: ListState = {
4243
status: 'NONE',
44+
scrollTop: null,
45+
scrollPtg: 0,
4346
itemIndex: 0,
4447
itemOffsetPtg: 0,
4548
startIndex: 0,
@@ -50,65 +53,83 @@ class List<T> extends React.Component<ListProps<T>, ListState> {
5053

5154
itemElements: { [index: number]: HTMLElement } = {};
5255

56+
itemElementHeights: { [index: number]: number } = {};
57+
5358
/**
54-
* Initial should sync with default scroll top
59+
* Phase 1: Initial should sync with default scroll top
5560
*/
5661
public componentDidMount() {
5762
this.listRef.current.scrollTop = 0;
5863
this.onScroll();
5964
}
6065

66+
/**
67+
* Phase 4: Record used item height
68+
* Phase 5: Trigger re-render to use correct position
69+
*/
6170
public componentDidUpdate() {
6271
const { status, startIndex, endIndex } = this.state;
6372
if (status === 'MEASURE_START') {
64-
const heightList: number[] = [];
73+
// Record here since measure item height will get warning in `render`
6574
for (let index = startIndex; index <= endIndex; index += 1) {
66-
const element: HTMLElement = this.itemElements[index];
67-
heightList[index] =
68-
'offsetHeight' in element
69-
? element.offsetHeight
70-
: (findDOMNode(element) as HTMLElement).offsetHeight;
75+
this.itemElementHeights[index] = getNodeHeight(this.itemElements[index]);
7176
}
77+
7278
this.setState({ status: 'MEASURE_DONE' });
7379
}
7480
}
7581

82+
public getItemHeight = (index: number) => this.itemElementHeights[index] || 0;
83+
7684
/**
7785
* Phase 2: Trigger render since we should re-calculate current position.
7886
*/
7987
public onScroll = () => {
8088
const { dataSource, height, itemHeight } = this.props;
8189

82-
const scrollTopPtg = getScrollPercentage(this.listRef.current);
83-
const { index, offsetPtg } = getLocationItem(scrollTopPtg, dataSource.length);
90+
const { scrollTop } = this.listRef.current;
91+
92+
// Skip if `scrollTop` not change to avoid shake
93+
if (scrollTop === this.state.scrollTop) {
94+
return;
95+
}
96+
97+
const scrollPtg = getScrollPercentage(this.listRef.current);
98+
99+
const { index, offsetPtg } = getLocationItem(scrollPtg, dataSource.length);
84100
const visibleCount = Math.ceil(height / itemHeight);
85101

86-
const beforeCount = Math.ceil(scrollTopPtg * visibleCount);
87-
const afterCount = Math.ceil((1 - scrollTopPtg) * visibleCount);
102+
const beforeCount = Math.ceil(scrollPtg * visibleCount);
103+
const afterCount = Math.ceil((1 - scrollPtg) * visibleCount);
88104

89105
this.setState({
90106
status: 'MEASURE_START',
107+
scrollTop,
108+
scrollPtg,
91109
itemIndex: index,
92110
itemOffsetPtg: offsetPtg,
93111
startIndex: Math.max(0, index - beforeCount),
94112
endIndex: Math.min(dataSource.length - 1, index + afterCount),
95113
});
96114
};
97115

98-
public renderChildren = (list: T[], renderFunc: RenderFunc<T>) =>
116+
/**
117+
* Phase 4: Render item and get all the visible items height
118+
*/
119+
public renderChildren = (list: T[], startIndex: number, renderFunc: RenderFunc<T>) =>
99120
// We should measure rendered item height
100-
list.map((item, index) => {
121+
list.map((item, index) => {
101122
const node = renderFunc(item) as React.ReactElement;
123+
const eleIndex = startIndex + index;
102124

103125
// Pass `key` and `ref` for internal measure
104126
return React.cloneElement(node, {
105-
key: index,
127+
key: eleIndex,
106128
ref: (ele: HTMLElement) => {
107-
this.itemElements[index] = ele;
129+
this.itemElements[eleIndex] = ele;
108130
},
109131
});
110-
})
111-
;
132+
});
112133

113134
public render() {
114135
const {
@@ -125,28 +146,44 @@ class List<T> extends React.Component<ListProps<T>, ListState> {
125146
if (height === undefined) {
126147
return (
127148
<Component style={style} {...restProps}>
128-
{this.renderChildren(dataSource, children)}
149+
{this.renderChildren(dataSource, 0, children)}
129150
</Component>
130151
);
131152
}
132153

133-
const { itemIndex, startIndex, endIndex } = this.state;
154+
const { status, startIndex, endIndex, itemIndex, itemOffsetPtg, scrollPtg } = this.state;
134155

135156
const contentHeight = dataSource.length * itemHeight;
136157

158+
// TODO: refactor
159+
let startItemTop = 0;
160+
if (status === 'MEASURE_DONE') {
161+
const locatedItemHeight = this.getItemHeight(itemIndex);
162+
const locatedItemTop = scrollPtg * this.listRef.current.clientHeight;
163+
const locatedItemOffset = itemOffsetPtg * locatedItemHeight;
164+
const locatedItemMergedTop =
165+
this.listRef.current.scrollTop + locatedItemTop - locatedItemOffset;
166+
167+
startItemTop = locatedItemMergedTop;
168+
for (let index = itemIndex - 1; index >= startIndex; index -= 1) {
169+
startItemTop -= this.getItemHeight(index);
170+
}
171+
}
172+
137173
return (
138174
<Component
139175
style={{
140176
...style,
141177
height,
142178
overflowY: 'auto',
179+
overflowAnchor: 'none',
143180
}}
144181
{...restProps}
145182
onScroll={this.onScroll}
146183
ref={this.listRef}
147184
>
148-
<Filler height={contentHeight}>
149-
{this.renderChildren(dataSource.slice(startIndex, endIndex + 1), children)}
185+
<Filler height={contentHeight} offset={status === 'MEASURE_DONE' ? startItemTop : 0}>
186+
{this.renderChildren(dataSource.slice(startIndex, endIndex + 1), startIndex, children)}
150187
</Filler>
151188
</Component>
152189
);

src/util.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { findDOMNode } from 'react-dom';
2+
13
interface LocationItemResult {
24
/** Located item index */
35
index: number;
@@ -9,13 +11,13 @@ interface LocationItemResult {
911
* Get location item and its align percentage with the scroll percentage.
1012
* We should measure current scroll position to decide which item is the location item.
1113
* And then fill the top count and bottom count with the base of location item.
14+
*
15+
* `total` should be the real count instead of `total - 1` in calculation.
1216
*/
1317
export function getLocationItem(scrollPtg: number, total: number): LocationItemResult {
14-
const measureTotal = total - 1;
15-
16-
const itemIndex = Math.floor(scrollPtg * measureTotal);
17-
const itemTopPtg = itemIndex / measureTotal;
18-
const itemBottomPtg = (itemIndex + 1) / measureTotal;
18+
const itemIndex = Math.floor(scrollPtg * total);
19+
const itemTopPtg = itemIndex / total;
20+
const itemBottomPtg = (itemIndex + 1) / total;
1921
const itemOffsetPtg = (scrollPtg - itemTopPtg) / (itemBottomPtg - itemTopPtg);
2022

2123
return {
@@ -33,3 +35,17 @@ export function getScrollPercentage(element: HTMLElement | null) {
3335
const scrollTopPtg = scrollTop / (scrollHeight - clientHeight);
3436
return scrollTopPtg;
3537
}
38+
39+
/**
40+
* Get node `offsetHeight`. We prefer node is a dom element directly.
41+
* But if not provided, downgrade to `findDOMNode` to get the real dom element.
42+
*/
43+
export function getNodeHeight(node: HTMLElement) {
44+
if (!node) {
45+
return 0;
46+
}
47+
48+
return 'offsetHeight' in node
49+
? node.offsetHeight
50+
: (findDOMNode(node) as HTMLElement).offsetHeight;
51+
}

0 commit comments

Comments
 (0)