Skip to content

Commit bf9d7ef

Browse files
authored
Merge pull request #1 from react-component/basic
feat: Support basic virtual scroll
2 parents 3e391b6 + 87e1c5e commit bf9d7ef

File tree

9 files changed

+420
-3
lines changed

9 files changed

+420
-3
lines changed

.circleci/config.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
version: 2
2+
jobs:
3+
lint:
4+
docker:
5+
- image: circleci/node:latest
6+
steps:
7+
- checkout
8+
- restore_cache:
9+
keys:
10+
- v1-dependencies-{{ checksum "package.json" }}
11+
- run: npm install
12+
- save_cache:
13+
paths:
14+
- node_modules
15+
key: v1-dependencies-{{ checksum "package.json" }}
16+
- run: npm run lint
17+
test:
18+
docker:
19+
- image: circleci/node:latest
20+
working_directory: ~/repo
21+
steps:
22+
- checkout
23+
- restore_cache:
24+
keys:
25+
- v1-dependencies-{{ checksum "package.json" }}
26+
- run: npm install
27+
- save_cache:
28+
paths:
29+
- node_modules
30+
key: v1-dependencies-{{ checksum "package.json" }}
31+
- run: npm test -- --coverage && bash <(curl -s https://codecov.io/bash)
32+
workflows:
33+
version: 2
34+
build_and_test:
35+
jobs:
36+
- lint
37+
- test

.eslintrc.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
const base = require('@umijs/fabric/dist/eslint');
2+
3+
module.exports = {
4+
...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+
},
11+
};

examples/basic.tsx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import * as React from 'react';
2+
import List from '../src/List';
3+
4+
interface Item {
5+
id: number;
6+
}
7+
8+
const MyItem: React.FC<Item> = ({ id }, ref) => {
9+
return (
10+
<span
11+
ref={ref}
12+
style={{
13+
border: '1px solid gray',
14+
padding: '0 16px',
15+
height: 30,
16+
lineHeight: '30px',
17+
boxSizing: 'border-box',
18+
display: 'inline-block',
19+
}}
20+
>
21+
{id}
22+
</span>
23+
);
24+
};
25+
26+
const ForwardMyItem = React.forwardRef(MyItem);
27+
28+
class TestItem extends React.Component {
29+
render() {
30+
return <div style={{ lineHeight: '30px' }}>{this.props.id}</div>;
31+
}
32+
}
33+
34+
const dataSource: Item[] = [];
35+
for (let i = 0; i < 100; i += 1) {
36+
dataSource.push({
37+
id: i,
38+
});
39+
}
40+
41+
const TYPES = [
42+
{ name: 'ref real dom element', type: 'dom', component: ForwardMyItem },
43+
{ name: 'ref react node', type: 'react', component: TestItem },
44+
];
45+
46+
const Demo = () => {
47+
const [type, setType] = React.useState('dom');
48+
49+
return (
50+
<React.StrictMode>
51+
<div>
52+
<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+
67+
<List
68+
dataSource={dataSource}
69+
height={200}
70+
itemHeight={30}
71+
style={{
72+
border: '1px solid red',
73+
boxSizing: 'border-box',
74+
}}
75+
>
76+
{item => (type === 'dom' ? <ForwardMyItem {...item} /> : <TestItem {...item} />)}
77+
</List>
78+
</div>
79+
</React.StrictMode>
80+
);
81+
};
82+
83+
export default Demo;

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"main": "./lib/index",
2727
"module": "./es/index",
2828
"scripts": {
29-
"start": "father doc dev --storybook",
29+
"start": "cross-env NODE_ENV=development father doc dev --storybook",
3030
"build": "father doc build --storybook",
3131
"compile": "father build",
3232
"prepublishOnly": "npm run compile && np --no-cleanup --yolo --no-publish",
@@ -35,13 +35,15 @@
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",
4243
"@types/react": "^16.8.19",
4344
"@types/react-dom": "^16.8.4",
4445
"@types/warning": "^3.0.0",
46+
"cross-env": "^5.2.0",
4547
"enzyme": "^3.1.0",
4648
"enzyme-adapter-react-16": "^1.0.2",
4749
"enzyme-to-json": "^3.1.4",

src/Filler.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import * as React from 'react';
2+
3+
interface FillerProps {
4+
/** Virtual filler height. Should be `count * itemMinHeight` */
5+
height: number;
6+
/** Set offset of visible items. Should be the top of start item position */
7+
offset: number;
8+
9+
children: React.ReactNode;
10+
}
11+
12+
/**
13+
* Fill component to provided the scroll content real height.
14+
*/
15+
const Filler: React.FC<FillerProps> = ({ height, offset, children }): React.ReactElement => (
16+
<div style={{ height, position: 'relative', overflow: 'hidden' }}>
17+
<div
18+
style={{
19+
marginTop: offset,
20+
position: 'absolute',
21+
left: 0,
22+
right: 0,
23+
top: 0,
24+
display: 'flex',
25+
flexDirection: 'column',
26+
}}
27+
>
28+
{children}
29+
</div>
30+
</div>
31+
);
32+
33+
export default Filler;

src/List.tsx

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import * as React from 'react';
2+
import Filler from './Filler';
3+
import { getLocationItem, getScrollPercentage, getNodeHeight } from './util';
4+
5+
type RenderFunc<T> = (item: T) => React.ReactNode;
6+
7+
export interface ListProps<T> extends React.HTMLAttributes<any> {
8+
children: RenderFunc<T>;
9+
dataSource: T[];
10+
height?: number;
11+
itemHeight?: number;
12+
component?: string | React.FC<any> | React.ComponentClass<any>;
13+
}
14+
15+
interface ListState {
16+
status: 'NONE' | 'MEASURE_START' | 'MEASURE_DONE';
17+
18+
scrollTop: number | null;
19+
scrollPtg: number;
20+
itemIndex: number;
21+
itemOffsetPtg: number;
22+
startIndex: number;
23+
endIndex: number;
24+
}
25+
26+
/**
27+
* We use class component here since typescript can not support generic in function component
28+
*
29+
* Virtual list display logic:
30+
* 1. scroll / initialize trigger measure
31+
* 2. Get location item of current `scrollTop`
32+
* 3. [Render] Render visible items
33+
* 4. Get all the visible items height
34+
* 5. [Render] Update top item `margin-top` to fit the position
35+
*/
36+
class List<T> extends React.Component<ListProps<T>, ListState> {
37+
static defaultProps = {
38+
itemHeight: 15,
39+
dataSource: [],
40+
};
41+
42+
state: ListState = {
43+
status: 'NONE',
44+
scrollTop: null,
45+
scrollPtg: 0,
46+
itemIndex: 0,
47+
itemOffsetPtg: 0,
48+
startIndex: 0,
49+
endIndex: 0,
50+
};
51+
52+
listRef = React.createRef<HTMLElement>();
53+
54+
itemElements: { [index: number]: HTMLElement } = {};
55+
56+
itemElementHeights: { [index: number]: number } = {};
57+
58+
/**
59+
* Phase 1: Initial should sync with default scroll top
60+
*/
61+
public componentDidMount() {
62+
this.listRef.current.scrollTop = 0;
63+
this.onScroll();
64+
}
65+
66+
/**
67+
* Phase 4: Record used item height
68+
* Phase 5: Trigger re-render to use correct position
69+
*/
70+
public componentDidUpdate() {
71+
const { status, startIndex, endIndex } = this.state;
72+
if (status === 'MEASURE_START') {
73+
// Record here since measure item height will get warning in `render`
74+
for (let index = startIndex; index <= endIndex; index += 1) {
75+
this.itemElementHeights[index] = getNodeHeight(this.itemElements[index]);
76+
}
77+
78+
this.setState({ status: 'MEASURE_DONE' });
79+
}
80+
}
81+
82+
public getItemHeight = (index: number) => this.itemElementHeights[index] || 0;
83+
84+
/**
85+
* Phase 2: Trigger render since we should re-calculate current position.
86+
*/
87+
public onScroll = () => {
88+
const { dataSource, height, itemHeight } = this.props;
89+
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);
100+
const visibleCount = Math.ceil(height / itemHeight);
101+
102+
const beforeCount = Math.ceil(scrollPtg * visibleCount);
103+
const afterCount = Math.ceil((1 - scrollPtg) * visibleCount);
104+
105+
this.setState({
106+
status: 'MEASURE_START',
107+
scrollTop,
108+
scrollPtg,
109+
itemIndex: index,
110+
itemOffsetPtg: offsetPtg,
111+
startIndex: Math.max(0, index - beforeCount),
112+
endIndex: Math.min(dataSource.length - 1, index + afterCount),
113+
});
114+
};
115+
116+
/**
117+
* Phase 4: Render item and get all the visible items height
118+
*/
119+
public renderChildren = (list: T[], startIndex: number, renderFunc: RenderFunc<T>) =>
120+
// We should measure rendered item height
121+
list.map((item, index) => {
122+
const node = renderFunc(item) as React.ReactElement;
123+
const eleIndex = startIndex + index;
124+
125+
// Pass `key` and `ref` for internal measure
126+
return React.cloneElement(node, {
127+
key: eleIndex,
128+
ref: (ele: HTMLElement) => {
129+
this.itemElements[eleIndex] = ele;
130+
},
131+
});
132+
});
133+
134+
public render() {
135+
const {
136+
style,
137+
component: Component = 'div',
138+
height,
139+
itemHeight,
140+
dataSource,
141+
children,
142+
...restProps
143+
} = this.props;
144+
145+
// Render pure list if not set height
146+
if (height === undefined) {
147+
return (
148+
<Component style={style} {...restProps}>
149+
{this.renderChildren(dataSource, 0, children)}
150+
</Component>
151+
);
152+
}
153+
154+
const { status, startIndex, endIndex, itemIndex, itemOffsetPtg, scrollPtg } = this.state;
155+
156+
const contentHeight = dataSource.length * itemHeight;
157+
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+
173+
return (
174+
<Component
175+
style={{
176+
...style,
177+
height,
178+
overflowY: 'auto',
179+
overflowAnchor: 'none',
180+
}}
181+
{...restProps}
182+
onScroll={this.onScroll}
183+
ref={this.listRef}
184+
>
185+
<Filler height={contentHeight} offset={status === 'MEASURE_DONE' ? startItemTop : 0}>
186+
{this.renderChildren(dataSource.slice(startIndex, endIndex + 1), startIndex, children)}
187+
</Filler>
188+
</Component>
189+
);
190+
}
191+
}
192+
193+
export default List;

src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
export default {};
1+
import List from './List';
2+
3+
export default List;

0 commit comments

Comments
 (0)